import email
import re
from dataclasses import dataclass
from re import Match
from typing import Union, cast
from deprecated import deprecated
from hamcrest import anything
from hamcrest.core.base_matcher import BaseMatcher
from hamcrest.core.description import Description
from hamcrest.core.helpers.wrap_matcher import wrap_matcher
from hamcrest.core.matcher import Matcher
from brunns.matchers.utils import (
append_matcher_description,
describe_field_match,
describe_field_mismatch,
)
ANYTHING = anything()
[docs]
@dataclass
class Email:
to_name: str
to_address: str
from_name: str
from_address: str
subject: str
body_text: str
[docs]
def is_email() -> "EmailWith":
"""Matches a string as an RFC 822 / MIME email message.
This function returns an :class:`EmailWith` matcher which can be refined using builder methods
(e.g., ``.with_subject(...)``). It parses the string using Python's :mod:`email` module.
:return: A matcher for email strings.
"""
return EmailWith()
[docs]
class EmailWith(BaseMatcher[str]):
def __init__(
self,
to_name: Union[str, Matcher[str]] = ANYTHING,
to_address: Union[str, Matcher[str]] = ANYTHING,
from_name: Union[str, Matcher[str]] = ANYTHING,
from_address: Union[str, Matcher[str]] = ANYTHING,
subject: Union[str, Matcher[str]] = ANYTHING,
body_text: Union[str, Matcher[str]] = ANYTHING,
) -> None:
self.to_name: Matcher[str] = wrap_matcher(to_name)
self.to_address: Matcher[str] = wrap_matcher(to_address)
self.from_name: Matcher[str] = wrap_matcher(from_name)
self.from_address: Matcher[str] = wrap_matcher(from_address)
self.subject: Matcher[str] = wrap_matcher(subject)
self.body_text: Matcher[str] = wrap_matcher(body_text)
def _matches(self, actual_email: str) -> bool:
email = self._parse_email(actual_email)
return (
self.to_name.matches(email.to_name)
and self.to_address.matches(email.to_address)
and self.from_name.matches(email.from_name)
and self.from_address.matches(email.from_address)
and self.subject.matches(email.subject)
and self.body_text.matches(email.body_text)
)
@staticmethod
def _parse_email(actual_email: str) -> Email:
parsed = email.message_from_string(actual_email)
# Handle cases where To/From might be missing or malformed gracefully?
# Current implementation assumes valid format per regex below.
actual_to_name, actual_to_address = cast("Match", re.match("(.*) <(.*)>", parsed["To"])).groups()
actual_from_name, actual_from_address = cast("Match", re.match("(.*) <(.*)>", parsed["From"])).groups()
actual_subject = parsed["Subject"]
actual_body_text = cast("str", parsed.get_payload())
return Email(
to_name=actual_to_name,
to_address=actual_to_address,
from_name=actual_from_name,
from_address=actual_from_address,
subject=actual_subject,
body_text=actual_body_text,
)
[docs]
def describe_to(self, description: Description) -> None:
description.append_text("email with")
append_matcher_description(self.to_name, "to_name", description)
append_matcher_description(self.to_address, "to_address", description)
append_matcher_description(self.from_name, "from_name", description)
append_matcher_description(self.from_address, "from_address", description)
append_matcher_description(self.subject, "subject", description)
append_matcher_description(self.body_text, "body_text", description)
[docs]
def describe_mismatch(self, actual_email: str, mismatch_description: Description) -> None:
email = self._parse_email(actual_email)
mismatch_description.append_text("was email with")
describe_field_mismatch(self.to_name, "to_name", email.to_name, mismatch_description)
describe_field_mismatch(self.to_address, "to_address", email.to_address, mismatch_description)
describe_field_mismatch(self.from_name, "from_name", email.from_name, mismatch_description)
describe_field_mismatch(self.from_address, "from_address", email.from_address, mismatch_description)
describe_field_mismatch(self.subject, "subject", email.subject, mismatch_description)
describe_field_mismatch(self.body_text, "body", email.body_text, mismatch_description)
[docs]
def describe_match(self, actual_email: str, match_description: Description) -> None:
email = self._parse_email(actual_email)
match_description.append_text("was email with")
describe_field_match(self.to_name, "to_name", email.to_name, match_description)
describe_field_match(self.to_address, "to_address", email.to_address, match_description)
describe_field_match(self.from_name, "from_name", email.from_name, match_description)
describe_field_match(self.from_address, "from_address", email.from_address, match_description)
describe_field_match(self.subject, "subject", email.subject, match_description)
describe_field_match(self.body_text, "body", email.body_text, match_description)
[docs]
def with_to_name(self, to_name: Union[str, Matcher[str]]):
"""Matches if the email 'To' name matches the given value or matcher.
:param to_name: The expected recipient name or matcher.
:return: Self, for chaining.
"""
self.to_name = wrap_matcher(to_name)
return self
[docs]
def and_to_name(self, to_name: Union[str, Matcher[str]]):
"""Matches if the email 'To' name matches the given value or matcher.
A synonym for :meth:`with_to_name`.
:param to_name: The expected recipient name or matcher.
:return: Self, for chaining.
"""
return self.with_to_name(to_name)
[docs]
def with_to_address(self, to_address: Union[str, Matcher[str]]):
"""Matches if the email 'To' address matches the given value or matcher.
:param to_address: The expected recipient email address or matcher.
:return: Self, for chaining.
"""
self.to_address = wrap_matcher(to_address)
return self
[docs]
def and_to_address(self, to_address: Union[str, Matcher[str]]):
"""Matches if the email 'To' address matches the given value or matcher.
A synonym for :meth:`with_to_address`.
:param to_address: The expected recipient email address or matcher.
:return: Self, for chaining.
"""
return self.with_to_address(to_address)
[docs]
def with_from_name(self, from_name: Union[str, Matcher[str]]):
"""Matches if the email 'From' name matches the given value or matcher.
:param from_name: The expected sender name or matcher.
:return: Self, for chaining.
"""
self.from_name = wrap_matcher(from_name)
return self
[docs]
def and_from_name(self, from_name: Union[str, Matcher[str]]):
"""Matches if the email 'From' name matches the given value or matcher.
A synonym for :meth:`with_from_name`.
:param from_name: The expected sender name or matcher.
:return: Self, for chaining.
"""
return self.with_from_name(from_name)
[docs]
def with_from_address(self, from_address: Union[str, Matcher[str]]):
"""Matches if the email 'From' address matches the given value or matcher.
:param from_address: The expected sender email address or matcher.
:return: Self, for chaining.
"""
self.from_address = wrap_matcher(from_address)
return self
[docs]
def and_from_address(self, from_address: Union[str, Matcher[str]]):
"""Matches if the email 'From' address matches the given value or matcher.
A synonym for :meth:`with_from_address`.
:param from_address: The expected sender email address or matcher.
:return: Self, for chaining.
"""
return self.with_from_address(from_address)
[docs]
def with_subject(self, subject: Union[str, Matcher[str]]):
"""Matches if the email subject matches the given value or matcher.
:param subject: The expected subject string or matcher.
:return: Self, for chaining.
"""
self.subject = wrap_matcher(subject)
return self
[docs]
def and_subject(self, subject: Union[str, Matcher[str]]):
"""Matches if the email subject matches the given value or matcher.
A synonym for :meth:`with_subject`.
:param subject: The expected subject string or matcher.
:return: Self, for chaining.
"""
return self.with_subject(subject)
[docs]
def with_body_text(self, body_text: Union[str, Matcher[str]]):
"""Matches if the email body text matches the given value or matcher.
:param body_text: The expected body string or matcher.
:return: Self, for chaining.
"""
self.body_text = wrap_matcher(body_text)
return self
[docs]
def and_body_text(self, body_text: Union[str, Matcher[str]]):
"""Matches if the email body text matches the given value or matcher.
A synonym for :meth:`with_body_text`.
:param body_text: The expected body string or matcher.
:return: Self, for chaining.
"""
return self.with_body_text(body_text)
[docs]
@deprecated(version="2.3.0", reason="Use builder style is_email()")
def email_with(
to_name: Union[str, Matcher[str]] = ANYTHING,
to_address: Union[str, Matcher[str]] = ANYTHING,
from_name: Union[str, Matcher[str]] = ANYTHING,
from_address: Union[str, Matcher[str]] = ANYTHING,
subject: Union[str, Matcher[str]] = ANYTHING,
body_text: Union[str, Matcher[str]] = ANYTHING,
) -> Matcher: # pragma: no cover
"""Matches an email string with specific attributes.
.. deprecated:: 2.3.0
Use :func:`is_email` and its builder methods instead.
:param to_name: Expected recipient name.
:param to_address: Expected recipient email address.
:param from_name: Expected sender name.
:param from_address: Expected sender email address.
:param subject: Expected subject line.
:param body_text: Expected body content.
:return: A matcher for email strings.
"""
return EmailWith(
to_name=to_name,
to_address=to_address,
from_name=from_name,
from_address=from_address,
subject=subject,
body_text=body_text,
)