diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 2834942..391a64d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,3 +36,19 @@ jobs: cache-dependency-path: requirements*/*.txt - run: pip install tox - run: tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} + typing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + with: + python-version: '3.x' + cache: pip + cache-dependency-path: requirements*/*.txt + - name: cache mypy + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: ./.mypy_cache + key: mypy|${{ hashFiles('pyproject.toml') }} + - run: pip install tox + - run: tox run -e typing diff --git a/CHANGES.md b/CHANGES.md index 9796bf4..6840334 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,12 +6,16 @@ Unreleased - Use `pyproject.toml` for packaging metadata. - Use `flit_core` as build backend. - Apply code formatting and linting tools. +- Add static type annotations. - Deprecate the `__version__` attribute. Use feature detection or `importlib.metadata.version("flask-mail")` instead. - Indicate that the deprecated `is_bad_headers` will be removed in the next version. - Fix the `email_dispatched` signal to pass the current app as the sender and `message` as an argument, rather than the other way around. +- `Attachment.data` may not be `None`. +- `Attachment.content_type` will be detected based on `filename` and `data` + and will not be `None`. ## Version 0.9.1 diff --git a/pyproject.toml b/pyproject.toml index ce74e75..f02872a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ classifiers = [ "Framework :: Flask", "License :: OSI Approved :: BSD License", "Programming Language :: Python", + "Typing :: Typed", ] requires-python = ">=3.8" dependencies = [ @@ -75,7 +76,6 @@ select = [ "UP", # pyupgrade "W", # pycodestyle warning ] -ignore-init-module-imports = true [tool.ruff.lint.isort] force-single-line = true diff --git a/requirements/dev.txt b/requirements/dev.txt index 354d159..b0c9f93 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,20 +4,12 @@ # # pip-compile dev.in # -blinker==1.8.2 - # via - # -r typing.txt - # flask cachetools==5.3.3 # via tox cfgv==3.4.0 # via pre-commit chardet==5.2.0 # via tox -click==8.1.7 - # via - # -r typing.txt - # flask colorama==0.4.6 # via tox distlib==0.3.8 @@ -31,38 +23,13 @@ filelock==3.14.0 # via # tox # virtualenv -flask==3.0.3 - # via - # -r typing.txt - # flask-sqlalchemy -flask-sqlalchemy==3.1.1 - # via -r typing.txt identify==2.5.36 # via pre-commit -importlib-metadata==7.1.0 - # via - # -r typing.txt - # flask iniconfig==2.0.0 # via # -r tests.txt # -r typing.txt # pytest -itsdangerous==2.2.0 - # via - # -r typing.txt - # flask -jinja2==3.1.4 - # via - # -r typing.txt - # flask -markupsafe==2.1.5 - # via - # -r typing.txt - # jinja2 - # werkzeug -mock==5.1.0 - # via -r tests.txt mypy==1.10.0 # via -r typing.txt mypy-extensions==1.0.0 @@ -105,10 +72,6 @@ pyyaml==6.0.1 # via pre-commit speaklater==1.3 # via -r tests.txt -sqlalchemy==2.0.30 - # via - # -r typing.txt - # flask-sqlalchemy tomli==2.0.1 # via # -r tests.txt @@ -119,33 +82,14 @@ tomli==2.0.1 # tox tox==4.15.0 # via -r dev.in -types-docutils==0.21.0.20240423 - # via - # -r typing.txt - # types-pygments -types-pygments==2.18.0.20240506 - # via -r typing.txt -types-setuptools==69.5.0.20240522 - # via - # -r typing.txt - # types-pygments typing-extensions==4.11.0 # via # -r typing.txt # mypy - # sqlalchemy virtualenv==20.26.2 # via # pre-commit # tox -werkzeug==3.0.3 - # via - # -r typing.txt - # flask -zipp==3.18.2 - # via - # -r typing.txt - # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/tests.in b/requirements/tests.in index 4d96b18..e079f8a 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -1,2 +1 @@ pytest -speaklater diff --git a/requirements/tests.txt b/requirements/tests.txt index 2350e9d..4d02486 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -14,7 +14,5 @@ pluggy==1.5.0 # via pytest pytest==8.2.1 # via -r tests.in -speaklater==1.3 - # via -r tests.in tomli==2.0.1 # via pytest diff --git a/src/flask_mail/__init__.py b/src/flask_mail/__init__.py index 8d1b2b1..697ce0f 100644 --- a/src/flask_mail/__init__.py +++ b/src/flask_mail/__init__.py @@ -1,6 +1,10 @@ +from __future__ import annotations + +import collections.abc as c import re import smtplib import time +import typing as t import unicodedata import warnings from contextlib import contextmanager @@ -15,24 +19,30 @@ from email.utils import formatdate from email.utils import make_msgid from email.utils import parseaddr +from mimetypes import guess_type +from types import TracebackType import blinker from flask import current_app +from flask import Flask + +if t.TYPE_CHECKING: + import typing_extensions as te charset.add_charset("utf-8", charset.SHORTEST, None, "utf-8") class FlaskMailUnicodeDecodeError(UnicodeDecodeError): - def __init__(self, obj, *args): + def __init__(self, obj: t.Any, *args: t.Any) -> None: self.obj = obj - UnicodeDecodeError.__init__(self, *args) + super().__init__(*args) - def __str__(self): - original = UnicodeDecodeError.__str__(self) + def __str__(self) -> str: + original = super().__str__() return f"{original}. You passed in {self.obj!r} ({type(self.obj)})" -def force_text(s, encoding="utf-8", errors="strict"): +def force_text(s: t.Any, encoding: str = "utf-8", errors: str = "strict") -> str: """ Similar to smart_text, except that lazy instances are resolved to strings, rather than kept as lazy objects. @@ -41,21 +51,20 @@ def force_text(s, encoding="utf-8", errors="strict"): return s try: - if isinstance(s, str): - return s - elif isinstance(s, bytes): - s = str(s, encoding, errors) + if isinstance(s, bytes): + out = str(s, encoding, errors) else: - s = str(s) + out = str(s) except UnicodeDecodeError as e: if not isinstance(s, Exception): raise FlaskMailUnicodeDecodeError(s, *e.args) from e - else: - s = " ".join([force_text(arg, encoding, errors) for arg in s]) - return s + out = " ".join([force_text(arg, encoding, errors) for arg in s.args]) + + return out -def sanitize_subject(subject, encoding="utf-8"): + +def sanitize_subject(subject: str, encoding: str = "utf-8") -> str: try: subject.encode("ascii") except UnicodeEncodeError: @@ -63,18 +72,21 @@ def sanitize_subject(subject, encoding="utf-8"): subject = Header(subject, encoding).encode() except UnicodeEncodeError: subject = Header(subject, "utf-8").encode() + return subject -def sanitize_address(addr, encoding="utf-8"): +def sanitize_address(addr: str | tuple[str, str], encoding: str = "utf-8") -> str: if isinstance(addr, str): addr = parseaddr(force_text(addr)) + nm, addr = addr try: nm = Header(nm, encoding).encode() except UnicodeEncodeError: nm = Header(nm, "utf-8").encode() + try: addr.encode("ascii") except UnicodeEncodeError: # IDN @@ -85,41 +97,47 @@ def sanitize_address(addr, encoding="utf-8"): addr = "@".join([localpart, domain]) else: addr = Header(addr, encoding).encode() + return formataddr((nm, addr)) -def sanitize_addresses(addresses, encoding="utf-8"): - return map(lambda e: sanitize_address(e, encoding), addresses) +def sanitize_addresses( + addresses: c.Iterable[str | tuple[str, str]], encoding: str = "utf-8" +) -> list[str]: + return [sanitize_address(e, encoding) for e in addresses] -def _has_newline(line): +def _has_newline(line: str) -> bool: """Used by has_bad_header to check for \\r or \\n""" - if line and ("\r" in line or "\n" in line): - return True - return False + return "\n" in line or "\r" in line class Connection: """Handles connection to host.""" - def __init__(self, mail): + def __init__(self, mail: Mail) -> None: self.mail = mail + self.host: smtplib.SMTP | smtplib.SMTP_SSL | None = None + self.num_emails: int = 0 - def __enter__(self): + def __enter__(self) -> te.Self: if self.mail.suppress: self.host = None else: self.host = self.configure_host() self.num_emails = 0 - return self - def __exit__(self, exc_type, exc_value, tb): - if self.host: + def __exit__( + self, exc_type: type[BaseException], exc_value: BaseException, tb: TracebackType + ) -> None: + if self.host is not None: self.host.quit() - def configure_host(self): + def configure_host(self) -> smtplib.SMTP | smtplib.SMTP_SSL: + host: smtplib.SMTP | smtplib.SMTP_SSL + if self.mail.use_ssl: host = smtplib.SMTP_SSL(self.mail.server, self.mail.port) else: @@ -129,19 +147,21 @@ def configure_host(self): if self.mail.use_tls: host.starttls() + if self.mail.username and self.mail.password: host.login(self.mail.username, self.mail.password) return host - def send(self, message, envelope_from=None): + def send( + self, message: Message, envelope_from: str | tuple[str, str] | None = None + ) -> None: """Verifies and sends message. :param message: Message instance. :param envelope_from: Email address to be used in MAIL FROM command. """ assert message.send_to, "No recipients have been added" - assert message.sender, ( "The message does not specify a sender and a default sender " "has not been configured" @@ -153,7 +173,7 @@ def send(self, message, envelope_from=None): if message.date is None: message.date = time.time() - if self.host: + if self.host is not None: self.host.sendmail( sanitize_address(envelope_from or message.sender), list(sanitize_addresses(message.send_to)), @@ -162,24 +182,24 @@ def send(self, message, envelope_from=None): message.rcpt_options, ) - email_dispatched.send(current_app._get_current_object(), message=message) - + app = current_app._get_current_object() # type: ignore[attr-defined] + email_dispatched.send(app, message=message) self.num_emails += 1 if self.num_emails == self.mail.max_emails: self.num_emails = 0 + if self.host: self.host.quit() self.host = self.configure_host() - def send_message(self, *args, **kwargs): + def send_message(self, *args: t.Any, **kwargs: t.Any) -> None: """Shortcut for send(msg). Takes same arguments as Message constructor. :versionadded: 0.3.5 """ - self.send(Message(*args, **kwargs)) @@ -190,27 +210,47 @@ class BadHeaderError(Exception): class Attachment: """Encapsulates file attachment information. - :versionadded: 0.3.5 - :param filename: filename of attachment :param content_type: file mimetype :param data: the raw file data :param disposition: content-disposition (if any) + + .. versionchanged:: 0.10.0 + The `data` argument is required. + + .. versionadded: 0.3.5 """ def __init__( self, - filename=None, - content_type=None, - data=None, - disposition=None, - headers=None, + filename: str | None = None, + content_type: str | None = None, + data: str | bytes | None = None, + disposition: str | None = None, + headers: dict[str, str] | None = None, ): - self.filename = filename - self.content_type = content_type - self.data = data - self.disposition = disposition or "attachment" - self.headers = headers or {} + if data is None: + raise ValueError("The 'data' argument is required.") + + self.data: str | bytes = data + + if content_type is None and filename is not None: + content_type = guess_type(filename)[0] + + if content_type is None: + if isinstance(data, str): + content_type = "text/plain" + else: + content_type = "application/octet-stream" + + self.filename: str | None = filename + self.content_type: str = content_type + self.disposition: str = disposition or "attachment" + + if headers is None: + headers = {} + + self.headers: dict[str, str] = headers class Message: @@ -236,72 +276,80 @@ class Message: def __init__( self, - subject="", - recipients=None, - body=None, - html=None, - alts=None, - sender=None, - cc=None, - bcc=None, - attachments=None, - reply_to=None, - date=None, - charset=None, - extra_headers=None, - mail_options=None, - rcpt_options=None, + subject: str = "", + recipients: list[str | tuple[str, str]] | None = None, + body: str | None = None, + html: str | None = None, + alts: dict[str, str] | c.Iterable[tuple[str, str]] | None = None, + sender: str | tuple[str, str] | None = None, + cc: list[str | tuple[str, str]] | None = None, + bcc: list[str | tuple[str, str]] | None = None, + attachments: list[Attachment] | None = None, + reply_to: str | tuple[str, str] | None = None, + date: float | None = None, + charset: str | None = None, + extra_headers: dict[str, str] | None = None, + mail_options: list[str] | None = None, + rcpt_options: list[str] | None = None, ): sender = sender or current_app.extensions["mail"].default_sender if isinstance(sender, tuple): - sender = "{} <{}>".format(*sender) - - self.recipients = recipients or [] - self.subject = subject - self.sender = sender - self.reply_to = reply_to - self.cc = cc or [] - self.bcc = bcc or [] - self.body = body - self.alts = dict(alts or {}) - self.html = html - self.date = date - self.msgId = make_msgid() - self.charset = charset - self.extra_headers = extra_headers - self.mail_options = mail_options or [] - self.rcpt_options = rcpt_options or [] - self.attachments = attachments or [] + sender = f"{sender[0]} <{sender[1]}>" + + self.recipients: list[str | tuple[str, str]] = recipients or [] + self.subject: str = subject + self.sender: str | tuple[str, str] = sender # pyright: ignore + self.reply_to: str | tuple[str, str] | None = reply_to + self.cc: list[str | tuple[str, str]] = cc or [] + self.bcc: list[str | tuple[str, str]] = bcc or [] + self.body: str | None = body + self.alts: dict[str, str] = dict(alts or {}) + self.html: str | None = html + self.date: float | None = date + self.msgId: str = make_msgid() + self.charset: str | None = charset + self.extra_headers: dict[str, str] | None = extra_headers + self.mail_options: list[str] = mail_options or [] + self.rcpt_options: list[str] = rcpt_options or [] + self.attachments: list[Attachment] = attachments or [] @property - def send_to(self): - return set(self.recipients) | set(self.bcc or ()) | set(self.cc or ()) + def send_to(self) -> set[str | tuple[str, str]]: + out = set(self.recipients) + + if self.bcc: + out.update(self.bcc) + + if self.cc: + out.update(self.cc) + + return out @property - def html(self): + def html(self) -> str | None: # pyright: ignore return self.alts.get("html") @html.setter - def html(self, value): + def html(self, value: str | None) -> None: # pyright: ignore if value is None: self.alts.pop("html", None) else: self.alts["html"] = value - def _mimetext(self, text, subtype="plain"): + def _mimetext(self, text: str | None, subtype: str = "plain") -> MIMEText: """Creates a MIMEText object with the given subtype (default: 'plain') If the text is unicode, the utf-8 charset is used. """ charset = self.charset or "utf-8" - return MIMEText(text, _subtype=subtype, _charset=charset) + return MIMEText(text, _subtype=subtype, _charset=charset) # type: ignore[arg-type] - def _message(self): + def _message(self) -> MIMEBase: """Creates the email""" ascii_attachments = current_app.extensions["mail"].ascii_attachments encoding = self.charset or "utf-8" - attachments = self.attachments or [] + msg: MIMEBase if len(attachments) == 0 and not self.alts: # No html content and zero attachments means plain text @@ -315,8 +363,10 @@ def _message(self): msg = MIMEMultipart() alternative = MIMEMultipart("alternative") alternative.attach(self._mimetext(self.body, "plain")) + for mimetype, content in self.alts.items(): alternative.attach(self._mimetext(content, mimetype)) + msg.attach(alternative) if self.subject: @@ -324,7 +374,6 @@ def _message(self): msg["From"] = sanitize_address(self.sender, encoding) msg["To"] = ", ".join(list(set(sanitize_addresses(self.recipients, encoding)))) - msg["Date"] = formatdate(self.date, localtime=True) # see RFC 5322 section 3.6.4. msg["Message-ID"] = self.msgId @@ -340,54 +389,67 @@ def _message(self): msg[k] = v SPACES = re.compile(r"[\s]+", re.UNICODE) + for attachment in attachments: f = MIMEBase(*attachment.content_type.split("/")) f.set_payload(attachment.data) encode_base64(f) - filename = attachment.filename - if filename and ascii_attachments: - # force filename to ascii - filename = unicodedata.normalize("NFKD", filename) - filename = filename.encode("ascii", "ignore").decode("ascii") - filename = SPACES.sub(" ", filename).strip() - - try: - filename and filename.encode("ascii") - except UnicodeEncodeError: - filename = ("UTF8", "", filename) - - f.add_header( - "Content-Disposition", attachment.disposition, filename=filename - ) + if attachment.filename is not None: + filename = attachment.filename + + if ascii_attachments: + # force filename to ascii + filename = unicodedata.normalize("NFKD", attachment.filename) + filename = filename.encode("ascii", "ignore").decode("ascii") + filename = SPACES.sub(" ", filename).strip() + + try: + filename.encode("ascii") + except UnicodeEncodeError: + f.add_header( + "Content-Disposition", + attachment.disposition, + filename=("UTF8", "", filename), + ) + else: + f.add_header( + "Content-Disposition", attachment.disposition, filename=filename + ) for key, value in attachment.headers.items(): f.add_header(key, value) msg.attach(f) - msg.policy = policy.SMTP + msg.policy = policy.SMTP return msg - def as_string(self): + def as_string(self) -> str: return self._message().as_string() - def as_bytes(self): + def as_bytes(self) -> bytes: return self._message().as_bytes() - def __str__(self): + def __str__(self) -> str: return self.as_string() - def __bytes__(self): + def __bytes__(self) -> bytes: return self.as_bytes() - def has_bad_headers(self): + def has_bad_headers(self) -> bool: """Checks for bad headers i.e. newlines in subject, sender or recipients. RFC5322: Allows multiline CRLF with trailing whitespace (FWS) in headers """ + headers = [self.sender, *self.recipients] + + if self.reply_to: + headers.append(self.reply_to) - headers = [self.sender, self.reply_to] + self.recipients for header in headers: + if isinstance(header, tuple): + header = f"{header[0]} <{header[1]}>" + if _has_newline(header): return True @@ -402,9 +464,10 @@ def has_bad_headers(self): return True if len(line.strip()) == 0: return True + return False - def is_bad_headers(self): + def is_bad_headers(self) -> bool: warnings.warn( "'is_bad_headers' is renamed to 'has_bad_headers'. The old name is" " deprecated and will be removed in Flask-Mail 1.0.", @@ -413,12 +476,12 @@ def is_bad_headers(self): ) return self.has_bad_headers() - def send(self, connection): + def send(self, connection: Connection) -> None: """Verifies and sends the message.""" connection.send(self) - def add_recipient(self, recipient): + def add_recipient(self, recipient: str | tuple[str, str]) -> None: """Adds another recipient to the message. :param recipient: email address of recipient. @@ -428,12 +491,12 @@ def add_recipient(self, recipient): def attach( self, - filename=None, - content_type=None, - data=None, - disposition=None, - headers=None, - ): + filename: str | None = None, + content_type: str | None = None, + data: str | bytes | None = None, + disposition: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: """Adds an attachment to the message. :param filename: filename of attachment @@ -448,7 +511,7 @@ def attach( class _MailMixin: @contextmanager - def record_messages(self): + def record_messages(self) -> c.Iterator[list[Message]]: """Records all messages. Use in unit tests for example:: with mail.record_messages() as outbox: @@ -460,13 +523,13 @@ def record_messages(self): """ outbox = [] - def record(app, message): + def record(app: Flask, message: Message) -> None: outbox.append(message) with email_dispatched.connected_to(record): yield outbox - def send(self, message): + def send(self, message: Message) -> None: """Sends a single message instance. If TESTING is True the message will not actually be sent. @@ -476,7 +539,7 @@ def send(self, message): with self.connect() as connection: message.send(connection) - def send_message(self, *args, **kwargs): + def send_message(self, *args: t.Any, **kwargs: t.Any) -> None: """Shortcut for send(msg). Takes same arguments as Message constructor. @@ -486,9 +549,10 @@ def send_message(self, *args, **kwargs): self.send(Message(*args, **kwargs)) - def connect(self): + def connect(self) -> Connection: """Opens a connection to the mail host.""" app = getattr(self, "app", None) or current_app + try: return Connection(app.extensions["mail"]) except KeyError as err: @@ -500,17 +564,17 @@ def connect(self): class _Mail(_MailMixin): def __init__( self, - server, - username, - password, - port, - use_tls, - use_ssl, - default_sender, - debug, - max_emails, - suppress, - ascii_attachments=False, + server: str, + username: str | None, + password: str | None, + port: int | None, + use_tls: bool, + use_ssl: bool, + default_sender: str | None, + debug: int, + max_emails: int | None, + suppress: bool, + ascii_attachments: bool, ): self.server = server self.username = username @@ -526,19 +590,19 @@ def __init__( class Mail(_MailMixin): - """Manages email messaging - - :param app: Flask instance - """ + """Manages email messaging.""" - def __init__(self, app=None): + def __init__(self, app: Flask | None = None) -> None: self.app = app + if app is not None: - self.state = self.init_app(app) + self.state: _Mail | None = self.init_app(app) else: self.state = None - def init_mail(self, config, debug=False, testing=False): + def init_mail( + self, config: dict[str, t.Any], debug: bool | int = False, testing: bool = False + ) -> _Mail: return _Mail( config.get("MAIL_SERVER", "127.0.0.1"), config.get("MAIL_USERNAME"), @@ -553,13 +617,11 @@ def init_mail(self, config, debug=False, testing=False): config.get("MAIL_ASCII_ATTACHMENTS", False), ) - def init_app(self, app): + def init_app(self, app: Flask) -> _Mail: """Initializes your mail settings from the application settings. You can use this if you want to set up your Mail instance at configuration time. - - :param app: Flask application instance """ state = self.init_mail(app.config, app.debug, app.testing) @@ -568,13 +630,12 @@ def init_app(self, app): app.extensions["mail"] = state return state - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self.state, name, None) -signals = blinker.Namespace() - -email_dispatched = signals.signal( +signals: blinker.Namespace = blinker.Namespace() +email_dispatched: blinker.NamedSignal = signals.signal( "email-dispatched", doc=""" Signal sent when an email is dispatched. This signal will also be sent @@ -583,7 +644,7 @@ def __getattr__(self, name): ) -def __getattr__(name): +def __getattr__(name: str) -> t.Any: if name == "__version__": import importlib.metadata diff --git a/src/flask_mail/py.typed b/src/flask_mail/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 4e0a2e0..61ab092 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +import collections.abc as c + import pytest from flask import Flask @@ -5,7 +9,7 @@ @pytest.fixture -def app(): +def app() -> c.Iterator[Flask]: app = Flask(__name__) app.config.update( { @@ -19,5 +23,5 @@ def app(): @pytest.fixture -def mail(app): +def mail(app: Flask) -> Mail: return Mail(app) diff --git a/tests/test_connection.py b/tests/test_connection.py index 1bdcd04..bee8495 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,12 +1,16 @@ +from __future__ import annotations + from unittest import mock import pytest +from flask import Flask from flask_mail import BadHeaderError +from flask_mail import Mail from flask_mail import Message -def test_send_message(app, mail): +def test_send_message(app: Flask, mail: Mail) -> None: with mail.record_messages() as outbox: with mail.connect() as conn: conn.send_message( @@ -17,7 +21,7 @@ def test_send_message(app, mail): assert sent_msg.sender == app.extensions["mail"].default_sender -def test_send_single(app, mail): +def test_send_single(app: Flask, mail: Mail) -> None: with mail.record_messages() as outbox: with mail.connect() as conn: msg = Message( @@ -32,7 +36,7 @@ def test_send_single(app, mail): assert sent_msg.sender == app.extensions["mail"].default_sender -def test_send_many(app, mail): +def test_send_many(app: Flask, mail: Mail) -> None: with mail.record_messages() as outbox: with mail.connect() as conn: for _i in range(100): @@ -45,7 +49,7 @@ def test_send_many(app, mail): assert sent_msg.sender == app.extensions["mail"].default_sender -def test_send_without_sender(app, mail): +def test_send_without_sender(app: Flask, mail: Mail) -> None: app.extensions["mail"].default_sender = None msg = Message(subject="testing", recipients=["to@example.com"], body="testing") with mail.connect() as conn: @@ -53,21 +57,21 @@ def test_send_without_sender(app, mail): conn.send(msg) -def test_send_without_recipients(mail): +def test_send_without_recipients(mail: Mail) -> None: msg = Message(subject="testing", recipients=[], body="testing") with mail.connect() as conn: with pytest.raises(AssertionError): conn.send(msg) -def test_bad_header_subject(mail): +def test_bad_header_subject(mail: Mail) -> None: msg = Message(subject="testing\n\r", body="testing", recipients=["to@example.com"]) with mail.connect() as conn: with pytest.raises(BadHeaderError): conn.send(msg) -def test_sendmail_with_ascii_recipient(mail): +def test_sendmail_with_ascii_recipient(mail: Mail) -> None: with mail.connect() as conn: with mock.patch.object(conn, "host") as host: msg = Message( @@ -87,7 +91,7 @@ def test_sendmail_with_ascii_recipient(mail): ) -def test_sendmail_with_non_ascii_recipient(mail): +def test_sendmail_with_non_ascii_recipient(mail: Mail) -> None: with mail.connect() as conn: with mock.patch.object(conn, "host") as host: msg = Message( @@ -107,7 +111,7 @@ def test_sendmail_with_non_ascii_recipient(mail): ) -def test_sendmail_with_ascii_body(mail): +def test_sendmail_with_ascii_body(mail: Mail) -> None: with mail.connect() as conn: with mock.patch.object(conn, "host") as host: msg = Message( @@ -127,7 +131,7 @@ def test_sendmail_with_ascii_body(mail): ) -def test_sendmail_with_non_ascii_body(mail): +def test_sendmail_with_non_ascii_body(mail: Mail) -> None: with mail.connect() as conn: with mock.patch.object(conn, "host") as host: msg = Message( diff --git a/tests/test_initialization.py b/tests/test_initialization.py index 2b7fbc8..59495a9 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -1,3 +1,10 @@ -def test_init_mail(app, mail): +from __future__ import annotations + +from flask import Flask + +from flask_mail import Mail + + +def test_init_mail(app: Flask, mail: Mail) -> None: new_mail = mail.init_mail(app.config, app.debug, app.testing) assert mail.state.__dict__ == new_mail.__dict__ diff --git a/tests/test_mail.py b/tests/test_mail.py index 2ccde11..87172e9 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -1,7 +1,12 @@ +from __future__ import annotations + +from flask import Flask + +from flask_mail import Mail from flask_mail import Message -def test_send(app, mail): +def test_send(app: Flask, mail: Mail) -> None: with mail.record_messages() as outbox: msg = Message(subject="testing", recipients=["tester@example.com"], body="test") mail.send(msg) @@ -10,7 +15,7 @@ def test_send(app, mail): assert msg.sender == app.extensions["mail"].default_sender -def test_send_message(app, mail): +def test_send_message(app: Flask, mail: Mail) -> None: with mail.record_messages() as outbox: mail.send_message( subject="testing", recipients=["tester@example.com"], body="test" diff --git a/tests/test_message.py b/tests/test_message.py index f508779..78e76e8 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,24 +1,28 @@ +from __future__ import annotations + import base64 -import email +import email.message +import email.utils import re import time +from email.header import Header import pytest -from speaklater import make_lazy_string +from flask import Flask from flask_mail import BadHeaderError -from flask_mail import Header +from flask_mail import Mail from flask_mail import Message from flask_mail import sanitize_address -def test_init_message(app, mail): +def test_init_message(app: Flask, mail: Mail) -> None: msg = Message(subject="subject", recipients=["to@example.com"]) assert msg.sender == app.extensions["mail"].default_sender assert msg.recipients == ["to@example.com"] -def test_init_message_recipients(app, mail): +def test_init_message_recipients(app: Flask, mail: Mail) -> None: msg = Message(subject="subject") assert msg.recipients == [] @@ -27,7 +31,7 @@ def test_init_message_recipients(app, mail): assert len(msg2.recipients) == 1 -def test_esmtp_options_properly_initialized(app, mail): +def test_esmtp_options_properly_initialized(app: Flask, mail: Mail) -> None: msg = Message(subject="subject") assert msg.mail_options == [] assert msg.rcpt_options == [] @@ -39,7 +43,7 @@ def test_esmtp_options_properly_initialized(app, mail): assert msg2.rcpt_options == ["NOTIFY=SUCCESS"] -def test_sendto_properly_set(app, mail): +def test_sendto_properly_set(app: Flask, mail: Mail) -> None: msg = Message( subject="subject", recipients=["somebody@here.com"], @@ -51,24 +55,24 @@ def test_sendto_properly_set(app, mail): assert len(msg.send_to) == 3 -def test_add_recipient(app, mail): +def test_add_recipient(app: Flask, mail: Mail) -> None: msg = Message("testing") msg.add_recipient("to@example.com") assert msg.recipients == ["to@example.com"] -def test_sender_as_tuple(app, mail): +def test_sender_as_tuple(app: Flask, mail: Mail) -> None: msg = Message(subject="testing", sender=("tester", "tester@example.com")) assert "tester " == msg.sender -def test_default_sender_as_tuple(app, mail): +def test_default_sender_as_tuple(app: Flask, mail: Mail) -> None: app.extensions["mail"].default_sender = ("tester", "tester@example.com") msg = Message(subject="testing") assert "tester " == msg.sender -def test_reply_to(app, mail): +def test_reply_to(app: Flask, mail: Mail) -> None: msg = Message( subject="testing", recipients=["to@example.com"], @@ -84,21 +88,21 @@ def test_reply_to(app, mail): assert h.encode() in str(response) -def test_send_without_sender(app, mail): +def test_send_without_sender(app: Flask, mail: Mail) -> None: app.extensions["mail"].default_sender = None msg = Message(subject="testing", recipients=["to@example.com"], body="testing") with pytest.raises(AssertionError): mail.send(msg) -def test_send_without_recipients(app, mail): +def test_send_without_recipients(app: Flask, mail: Mail) -> None: app.extensions["mail"].default_sender = None msg = Message(subject="testing", recipients=[], body="testing") with pytest.raises(AssertionError): mail.send(msg) -def test_bcc(app, mail): +def test_bcc(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="testing", @@ -110,7 +114,7 @@ def test_bcc(app, mail): assert "tosomeoneelse@example.com" not in str(response) -def test_cc(app, mail): +def test_cc(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="testing", @@ -122,7 +126,7 @@ def test_cc(app, mail): assert "Cc: tosomeoneelse@example.com" in str(response) -def test_attach(app, mail): +def test_attach(app: Flask, mail: Mail) -> None: msg = Message(subject="testing", recipients=["to@example.com"], body="testing") msg.attach(data=b"this is a test", content_type="text/plain") a = msg.attachments[0] @@ -132,7 +136,7 @@ def test_attach(app, mail): assert a.data == b"this is a test" -def test_bad_header_subject(app, mail): +def test_bad_header_subject(app: Flask, mail: Mail) -> None: msg = Message( subject="testing\r\n", sender="from@example.com", @@ -143,7 +147,7 @@ def test_bad_header_subject(app, mail): mail.send(msg) -def test_multiline_subject(app, mail): +def test_multiline_subject(app: Flask, mail: Mail) -> None: msg = Message( subject="testing\r\n testing\r\n testing \r\n \ttesting", sender="from@example.com", @@ -156,7 +160,7 @@ def test_multiline_subject(app, mail): assert "testing\r\n testing\r\n testing \r\n \ttesting" in str(response) -def test_bad_multiline_subject(app, mail): +def test_bad_multiline_subject(app: Flask, mail: Mail) -> None: msg = Message( subject="testing\r\n testing\r\n ", sender="from@example.com", @@ -185,7 +189,7 @@ def test_bad_multiline_subject(app, mail): mail.send(msg) -def test_bad_header_sender(app, mail): +def test_bad_header_sender(app: Flask, mail: Mail) -> None: msg = Message( subject="testing", sender="from@example.com\r\n", @@ -196,7 +200,7 @@ def test_bad_header_sender(app, mail): assert "From: from@example.com" in msg.as_string() -def test_bad_header_reply_to(app, mail): +def test_bad_header_reply_to(app: Flask, mail: Mail) -> None: msg = Message( subject="testing", sender="from@example.com", @@ -210,7 +214,7 @@ def test_bad_header_reply_to(app, mail): assert "Reply-To: evil@example.com" in msg.as_string() -def test_bad_header_recipient(app, mail): +def test_bad_header_recipient(app: Flask, mail: Mail) -> None: msg = Message( subject="testing", sender="from@example.com", @@ -221,7 +225,7 @@ def test_bad_header_recipient(app, mail): assert "To: to@example.com" in msg.as_string() -def test_emails_are_sanitized(app, mail): +def test_emails_are_sanitized(app: Flask, mail: Mail) -> None: msg = Message( subject="testing", sender="sender\r\n@example.com", @@ -233,7 +237,7 @@ def test_emails_are_sanitized(app, mail): assert "recipient@example.com" in msg.as_string() -def test_plain_message(app, mail): +def test_plain_message(app: Flask, mail: Mail) -> None: plain_text = "Hello Joe,\nHow are you?" msg = Message( sender="from@example.com", @@ -245,7 +249,7 @@ def test_plain_message(app, mail): assert "Content-Type: text/plain" in msg.as_string() -def test_message_str(app, mail): +def test_message_str(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="subject", @@ -255,7 +259,7 @@ def test_message_str(app, mail): assert msg.as_string() == str(msg) -def test_plain_message_with_attachments(app, mail): +def test_plain_message_with_attachments(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="subject", @@ -268,7 +272,7 @@ def test_plain_message_with_attachments(app, mail): assert "Content-Type: multipart/mixed" in msg.as_string() -def test_plain_message_with_ascii_attachment(app, mail): +def test_plain_message_with_ascii_attachment(app: Flask, mail: Mail) -> None: msg = Message(subject="subject", recipients=["to@example.com"], body="hello") msg.attach( @@ -278,7 +282,7 @@ def test_plain_message_with_ascii_attachment(app, mail): assert 'Content-Disposition: attachment; filename="test doc.txt"' in msg.as_string() -def test_plain_message_with_unicode_attachment(app, mail): +def test_plain_message_with_unicode_attachment(app: Flask, mail: Mail) -> None: msg = Message(subject="subject", recipients=["to@example.com"], body="hello") msg.attach( @@ -288,15 +292,20 @@ def test_plain_message_with_unicode_attachment(app, mail): ) parsed = email.message_from_string(msg.as_string()) + payload = parsed.get_payload(1) + assert isinstance(payload, email.message.Message) + disposition = payload.get("Content-Disposition") + assert disposition is not None + disposition = re.sub(r"\s+", " ", disposition) - assert re.sub(r"\s+", " ", parsed.get_payload()[1].get("Content-Disposition")) in [ + assert disposition in [ "attachment; filename*=\"UTF8''%C3%BCnic%C3%B6de%20%E2%86%90%E2%86%92%20%E2%9C%93.txt\"", # noqa: E501 "attachment; filename*=UTF8''%C3%BCnic%C3%B6de%20%E2%86%90%E2%86%92%20%E2%9C%93.txt", # noqa: E501 ] -def test_plain_message_with_ascii_converted_attachment(app, mail): - mail.state.ascii_attachments = True +def test_plain_message_with_ascii_converted_attachment(app: Flask, mail: Mail) -> None: + mail.state.ascii_attachments = True # type: ignore[union-attr] msg = Message(subject="subject", recipients=["to@example.com"], body="hello") msg.attach( @@ -310,7 +319,7 @@ def test_plain_message_with_ascii_converted_attachment(app, mail): ) -def test_html_message(app, mail): +def test_html_message(app: Flask, mail: Mail) -> None: html_text = "

Hello World

" msg = Message( sender="from@example.com", @@ -323,7 +332,7 @@ def test_html_message(app, mail): assert "Content-Type: multipart/alternative" in msg.as_string() -def test_json_message(app, mail): +def test_json_message(app: Flask, mail: Mail) -> None: json_text = '{"msg": "Hello World!}' msg = Message( sender="from@example.com", @@ -336,7 +345,7 @@ def test_json_message(app, mail): assert "Content-Type: multipart/alternative" in msg.as_string() -def test_html_message_with_attachments(app, mail): +def test_html_message_with_attachments(app: Flask, mail: Mail) -> None: html_text = "

Hello World

" plain_text = "Hello World" msg = Message( @@ -354,16 +363,26 @@ def test_html_message_with_attachments(app, mail): parsed = email.message_from_string(msg.as_string()) assert len(parsed.get_payload()) == 2 - body, attachment = parsed.get_payload() + payload = parsed.get_payload() + assert isinstance(payload, list) + body, attachment = payload + assert isinstance(body, email.message.Message) + assert isinstance(attachment, email.message.Message) assert len(body.get_payload()) == 2 - plain, html = body.get_payload() + payload = body.get_payload() + assert isinstance(payload, list) + plain, html = payload + assert isinstance(plain, email.message.Message) + assert isinstance(html, email.message.Message) assert plain.get_payload() == plain_text assert html.get_payload() == html_text - assert base64.b64decode(attachment.get_payload()) == b"this is a test" + payload = attachment.get_payload() + assert isinstance(payload, str) + assert base64.b64decode(payload) == b"this is a test" -def test_date_header(app, mail): +def test_date_header(app: Flask, mail: Mail) -> None: before = time.time() msg = Message( sender="from@example.com", @@ -374,12 +393,13 @@ def test_date_header(app, mail): ) after = time.time() + assert msg.date is not None assert before <= msg.date <= after - dateFormatted = email.utils.formatdate(msg.date, localtime=True) - assert "Date: " + dateFormatted in msg.as_string() + formatted = email.utils.formatdate(msg.date, localtime=True) + assert "Date: " + formatted in msg.as_string() -def test_msgid_header(app, mail): +def test_msgid_header(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="subject", @@ -393,7 +413,7 @@ def test_msgid_header(app, mail): assert "Message-ID: " + msg.msgId in msg.as_string() -def test_unicode_sender_tuple(app, mail): +def test_unicode_sender_tuple(app: Flask, mail: Mail) -> None: msg = Message( subject="subject", sender=("ÄÜÖ → ✓", "from@example.com>"), @@ -405,7 +425,7 @@ def test_unicode_sender_tuple(app, mail): ) -def test_unicode_sender(app, mail): +def test_unicode_sender(app: Flask, mail: Mail) -> None: msg = Message( subject="subject", sender="ÄÜÖ → ✓ >", @@ -417,7 +437,7 @@ def test_unicode_sender(app, mail): ) -def test_unicode_headers(app, mail): +def test_unicode_headers(app: Flask, mail: Mail) -> None: msg = Message( subject="subject", sender="ÄÜÖ → ✓ ", @@ -443,16 +463,16 @@ def test_unicode_headers(app, mail): assert h3.encode() in response -def test_unicode_subject(app, mail): +def test_unicode_subject(app: Flask, mail: Mail) -> None: msg = Message( - subject=make_lazy_string(lambda a: a, "sübject"), + subject="sübject", sender="from@example.com", recipients=["to@example.com"], ) assert "=?utf-8?q?s=C3=BCbject?=" in msg.as_string() -def test_extra_headers(app, mail): +def test_extra_headers(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="subject", @@ -463,7 +483,7 @@ def test_extra_headers(app, mail): assert "X-Extra-Header: Yes" in msg.as_string() -def test_message_charset(app, mail): +def test_message_charset(app: Flask, mail: Mail) -> None: msg = Message( sender="from@example.com", subject="subject", @@ -568,7 +588,7 @@ def test_message_charset(app, mail): assert 'Content-Type: text/plain; charset="utf-8"' in msg.as_string() -def test_empty_subject_header(app, mail): +def test_empty_subject_header(app: Flask, mail: Mail) -> None: msg = Message(sender="from@example.com", recipients=["foo@bar.com"]) msg.body = "normal ascii text" mail.send(msg) diff --git a/tox.ini b/tox.ini index 70a441b..01dc21b 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py3{12,11,10,9,8} style + typing docs skip_missing_interpreters = true @@ -18,6 +19,13 @@ deps = pre-commit skip_install = true commands = pre-commit run --all-files +[testenv:typing] +deps = -r requirements/typing.txt +commands = + mypy + pyright + pyright --ignoreexternal --verifytypes flask_mail + [testenv:docs] deps = -r requirements/docs.txt commands = sphinx-build -E -W -b dirhtml docs docs/_build/dirhtml