Skip to content

Email

The email adapter provides integration with email services for sending transactional and notification emails.

Ports

Abstract port interface defining the email adapter contract.

Port interfaces for email operations.

archipy.adapters.email.ports.EmailPort

Interface for email sending operations.

This interface defines the contract for email adapters, ensuring a consistent approach to sending emails across different implementations. It provides a comprehensive set of features including support for:

  • Multiple recipients (To, CC, BCC)
  • HTML and plain text content
  • File and in-memory attachments
  • Template-based email rendering

Implementing classes should handle the details of connecting to an email service, managing connections, and ensuring reliable delivery.

Examples:

>>> from archipy.adapters.email.ports import EmailPort
>>>
>>> class CustomEmailAdapter(EmailPort):
...     def __init__(self, config):
...         self.config = config
...
...     def send_email(
...         self,
...         to_email,
...         subject,
...         body,
...         cc=None,
...         bcc=None,
...         attachments=None,
...         html=False,
...         template=None,
...         template_vars=None,
...     ):
...         # Implementation details...
...         pass
Source code in archipy/adapters/email/ports.py
class EmailPort:
    """Interface for email sending operations.

    This interface defines the contract for email adapters, ensuring
    a consistent approach to sending emails across different implementations.
    It provides a comprehensive set of features including support for:

    - Multiple recipients (To, CC, BCC)
    - HTML and plain text content
    - File and in-memory attachments
    - Template-based email rendering

    Implementing classes should handle the details of connecting to an
    email service, managing connections, and ensuring reliable delivery.

    Examples:
        >>> from archipy.adapters.email.ports import EmailPort
        >>>
        >>> class CustomEmailAdapter(EmailPort):
        ...     def __init__(self, config):
        ...         self.config = config
        ...
        ...     def send_email(
        ...         self,
        ...         to_email,
        ...         subject,
        ...         body,
        ...         cc=None,
        ...         bcc=None,
        ...         attachments=None,
        ...         html=False,
        ...         template=None,
        ...         template_vars=None,
        ...     ):
        ...         # Implementation details...
        ...         pass
    """

    @abstractmethod
    def send_email(
        self,
        to_email: EmailStr | list[EmailStr],
        subject: str,
        body: str,
        cc: EmailStr | list[EmailStr] | None = None,
        bcc: EmailStr | list[EmailStr] | None = None,
        attachments: list[str | EmailAttachmentDTO] | None = None,
        html: bool = False,
        template: str | None = None,
        template_vars: dict[str, Any] | None = None,
    ) -> None:
        """Send an email with various options and features.

        This method handles the composition and delivery of an email with
        support for multiple recipients, HTML content, templates, and attachments.

        Args:
            to_email: Primary recipient(s) of the email
            subject: Email subject line
            body: Email body content (either plain text or HTML)
            cc: Carbon copy recipient(s)
            bcc: Blind carbon copy recipient(s)
            attachments: List of file paths or EmailAttachmentDTO objects
            html: If True, treats body as HTML content, otherwise plain text
            template: A template string to render using template_vars
            template_vars: Variables to use when rendering the template

        Returns:
            None

        Raises:
            InvalidArgumentError: If the adapter is misconfigured or an attachment is invalid.
            UnavailableError: If the email cannot be delivered because the mail service is unavailable.

        Examples:
            >>> # Simple text email
            >>> adapter.send_email(to_email="user@example.com", subject="Hello", body="This is a test email")
            >>>
            >>> # HTML email with attachment
            >>> adapter.send_email(
            ...     to_email=["user1@example.com", "user2@example.com"],
            ...     subject="Report",
            ...     body="<h1>Monthly Report</h1><p>Please see attached</p>",
            ...     html=True,
            ...     attachments=["path/to/report.pdf"],
            ... )
            >>>
            >>> # Template-based email
            >>> template = "Hello {{ name }}, your account expires on {{ date }}"
            >>> adapter.send_email(
            ...     to_email="user@example.com",
            ...     subject="Account Expiration",
            ...     body="",  # Body will be rendered from template
            ...     template=template,
            ...     template_vars={"name": "John", "date": "2023-12-31"},
            ... )
        """
        raise NotImplementedError

archipy.adapters.email.ports.EmailPort.send_email abstractmethod

send_email(
    to_email: EmailStr | list[EmailStr],
    subject: str,
    body: str,
    cc: EmailStr | list[EmailStr] | None = None,
    bcc: EmailStr | list[EmailStr] | None = None,
    attachments: list[str | EmailAttachmentDTO]
    | None = None,
    html: bool = False,
    template: str | None = None,
    template_vars: dict[str, Any] | None = None,
) -> None

Send an email with various options and features.

This method handles the composition and delivery of an email with support for multiple recipients, HTML content, templates, and attachments.

Parameters:

Name Type Description Default
to_email EmailStr | list[EmailStr]

Primary recipient(s) of the email

required
subject str

Email subject line

required
body str

Email body content (either plain text or HTML)

required
cc EmailStr | list[EmailStr] | None

Carbon copy recipient(s)

None
bcc EmailStr | list[EmailStr] | None

Blind carbon copy recipient(s)

None
attachments list[str | EmailAttachmentDTO] | None

List of file paths or EmailAttachmentDTO objects

None
html bool

If True, treats body as HTML content, otherwise plain text

False
template str | None

A template string to render using template_vars

None
template_vars dict[str, Any] | None

Variables to use when rendering the template

None

Returns:

Type Description
None

None

Raises:

Type Description
InvalidArgumentError

If the adapter is misconfigured or an attachment is invalid.

UnavailableError

If the email cannot be delivered because the mail service is unavailable.

Examples:

>>> # Simple text email
>>> adapter.send_email(to_email="user@example.com", subject="Hello", body="This is a test email")
>>>
>>> # HTML email with attachment
>>> adapter.send_email(
...     to_email=["user1@example.com", "user2@example.com"],
...     subject="Report",
...     body="<h1>Monthly Report</h1><p>Please see attached</p>",
...     html=True,
...     attachments=["path/to/report.pdf"],
... )
>>>
>>> # Template-based email
>>> template = "Hello {{ name }}, your account expires on {{ date }}"
>>> adapter.send_email(
...     to_email="user@example.com",
...     subject="Account Expiration",
...     body="",  # Body will be rendered from template
...     template=template,
...     template_vars={"name": "John", "date": "2023-12-31"},
... )
Source code in archipy/adapters/email/ports.py
@abstractmethod
def send_email(
    self,
    to_email: EmailStr | list[EmailStr],
    subject: str,
    body: str,
    cc: EmailStr | list[EmailStr] | None = None,
    bcc: EmailStr | list[EmailStr] | None = None,
    attachments: list[str | EmailAttachmentDTO] | None = None,
    html: bool = False,
    template: str | None = None,
    template_vars: dict[str, Any] | None = None,
) -> None:
    """Send an email with various options and features.

    This method handles the composition and delivery of an email with
    support for multiple recipients, HTML content, templates, and attachments.

    Args:
        to_email: Primary recipient(s) of the email
        subject: Email subject line
        body: Email body content (either plain text or HTML)
        cc: Carbon copy recipient(s)
        bcc: Blind carbon copy recipient(s)
        attachments: List of file paths or EmailAttachmentDTO objects
        html: If True, treats body as HTML content, otherwise plain text
        template: A template string to render using template_vars
        template_vars: Variables to use when rendering the template

    Returns:
        None

    Raises:
        InvalidArgumentError: If the adapter is misconfigured or an attachment is invalid.
        UnavailableError: If the email cannot be delivered because the mail service is unavailable.

    Examples:
        >>> # Simple text email
        >>> adapter.send_email(to_email="user@example.com", subject="Hello", body="This is a test email")
        >>>
        >>> # HTML email with attachment
        >>> adapter.send_email(
        ...     to_email=["user1@example.com", "user2@example.com"],
        ...     subject="Report",
        ...     body="<h1>Monthly Report</h1><p>Please see attached</p>",
        ...     html=True,
        ...     attachments=["path/to/report.pdf"],
        ... )
        >>>
        >>> # Template-based email
        >>> template = "Hello {{ name }}, your account expires on {{ date }}"
        >>> adapter.send_email(
        ...     to_email="user@example.com",
        ...     subject="Account Expiration",
        ...     body="",  # Body will be rendered from template
        ...     template=template,
        ...     template_vars={"name": "John", "date": "2023-12-31"},
        ... )
    """
    raise NotImplementedError

options: show_root_toc_entry: false heading_level: 3

Adapters

Concrete email adapter implementing SMTP-based email sending with ArchiPy conventions.

Email adapter implementations for ArchiPy.

archipy.adapters.email.adapters.logger module-attribute

logger = logging.getLogger(__name__)

archipy.adapters.email.adapters.EmailConnectionManager

Manages SMTP connections with connection pooling and timeout handling.

Source code in archipy/adapters/email/adapters.py
class EmailConnectionManager:
    """Manages SMTP connections with connection pooling and timeout handling."""

    def __init__(self, config: EmailConfig) -> None:
        """Initialize EmailConnectionManager."""
        self.config = config
        self.smtp_connection: smtplib.SMTP | None = None
        self.last_used: datetime | None = None
        self.last_error: Exception | None = None

    def connect(self) -> None:
        """Establish SMTP connection with optional STARTTLS and authentication.

        STARTTLS is negotiated only when the server advertises it, so plain SMTP relays work too.
        Failures are captured and leave ``smtp_connection`` as ``None``; the cause is kept in ``last_error``.

        Raises:
            InvalidArgumentError: If no SMTP server is configured.
        """
        if not self.config.SMTP_SERVER:
            raise InvalidArgumentError(argument_name="SMTP_SERVER")

        try:
            self.smtp_connection = smtplib.SMTP(
                self.config.SMTP_SERVER,
                self.config.SMTP_PORT,
                timeout=self.config.CONNECTION_TIMEOUT,
            )
            self.smtp_connection.ehlo()
            tls_mode = "none"
            if self.smtp_connection.has_extn("starttls"):
                self.smtp_connection.starttls()
                self.smtp_connection.ehlo()
                tls_mode = "starttls"
            _annotate_current_span({"email.tls_mode": tls_mode})
            if self.config.USERNAME and self.config.PASSWORD:
                self.smtp_connection.login(self.config.USERNAME, self.config.PASSWORD)
            self.last_used = DatetimeUtils.get_datetime_now()
            self.last_error = None
        except (smtplib.SMTPException, OSError, TimeoutError) as e:
            BaseUtils.capture_exception(e)
            self.smtp_connection = None
            self.last_error = e

    def disconnect(self) -> None:
        """Close SMTP connection safely."""
        try:
            if self.smtp_connection:
                self.smtp_connection.quit()
                self.smtp_connection = None
        except (smtplib.SMTPException, OSError, TimeoutError) as e:
            BaseUtils.capture_exception(e)
        finally:
            self.smtp_connection = None

    def refresh_if_needed(self) -> None:
        """Refresh connection if needed based on timeout."""
        if not self.smtp_connection or not self.last_used:
            self.connect()
            return

        time_diff = (DatetimeUtils.get_datetime_now() - self.last_used).total_seconds()
        if time_diff > self.config.CONNECTION_REFRESH_SECONDS:
            self.disconnect()
            self.connect()

archipy.adapters.email.adapters.EmailConnectionManager.config instance-attribute

config = config

archipy.adapters.email.adapters.EmailConnectionManager.smtp_connection instance-attribute

smtp_connection: SMTP | None = None

archipy.adapters.email.adapters.EmailConnectionManager.last_used instance-attribute

last_used: datetime | None = None

archipy.adapters.email.adapters.EmailConnectionManager.last_error instance-attribute

last_error: Exception | None = None

archipy.adapters.email.adapters.EmailConnectionManager.connect

connect() -> None

Establish SMTP connection with optional STARTTLS and authentication.

STARTTLS is negotiated only when the server advertises it, so plain SMTP relays work too. Failures are captured and leave smtp_connection as None; the cause is kept in last_error.

Raises:

Type Description
InvalidArgumentError

If no SMTP server is configured.

Source code in archipy/adapters/email/adapters.py
def connect(self) -> None:
    """Establish SMTP connection with optional STARTTLS and authentication.

    STARTTLS is negotiated only when the server advertises it, so plain SMTP relays work too.
    Failures are captured and leave ``smtp_connection`` as ``None``; the cause is kept in ``last_error``.

    Raises:
        InvalidArgumentError: If no SMTP server is configured.
    """
    if not self.config.SMTP_SERVER:
        raise InvalidArgumentError(argument_name="SMTP_SERVER")

    try:
        self.smtp_connection = smtplib.SMTP(
            self.config.SMTP_SERVER,
            self.config.SMTP_PORT,
            timeout=self.config.CONNECTION_TIMEOUT,
        )
        self.smtp_connection.ehlo()
        tls_mode = "none"
        if self.smtp_connection.has_extn("starttls"):
            self.smtp_connection.starttls()
            self.smtp_connection.ehlo()
            tls_mode = "starttls"
        _annotate_current_span({"email.tls_mode": tls_mode})
        if self.config.USERNAME and self.config.PASSWORD:
            self.smtp_connection.login(self.config.USERNAME, self.config.PASSWORD)
        self.last_used = DatetimeUtils.get_datetime_now()
        self.last_error = None
    except (smtplib.SMTPException, OSError, TimeoutError) as e:
        BaseUtils.capture_exception(e)
        self.smtp_connection = None
        self.last_error = e

archipy.adapters.email.adapters.EmailConnectionManager.disconnect

disconnect() -> None

Close SMTP connection safely.

Source code in archipy/adapters/email/adapters.py
def disconnect(self) -> None:
    """Close SMTP connection safely."""
    try:
        if self.smtp_connection:
            self.smtp_connection.quit()
            self.smtp_connection = None
    except (smtplib.SMTPException, OSError, TimeoutError) as e:
        BaseUtils.capture_exception(e)
    finally:
        self.smtp_connection = None

archipy.adapters.email.adapters.EmailConnectionManager.refresh_if_needed

refresh_if_needed() -> None

Refresh connection if needed based on timeout.

Source code in archipy/adapters/email/adapters.py
def refresh_if_needed(self) -> None:
    """Refresh connection if needed based on timeout."""
    if not self.smtp_connection or not self.last_used:
        self.connect()
        return

    time_diff = (DatetimeUtils.get_datetime_now() - self.last_used).total_seconds()
    if time_diff > self.config.CONNECTION_REFRESH_SECONDS:
        self.disconnect()
        self.connect()

archipy.adapters.email.adapters.EmailConnectionPool

Connection pool for managing multiple SMTP connections.

Source code in archipy/adapters/email/adapters.py
class EmailConnectionPool:
    """Connection pool for managing multiple SMTP connections."""

    def __init__(self, config: EmailConfig) -> None:
        """Initialize EmailConnectionPool."""
        self.config = config
        self.pool: Queue[EmailConnectionManager] = Queue(maxsize=config.POOL_SIZE)
        self._initialize_pool()

    def _initialize_pool(self) -> None:
        for _ in range(self.config.POOL_SIZE):
            connection = EmailConnectionManager(self.config)
            self.pool.put(connection)

    def get_connection(self) -> EmailConnectionManager:
        """Get a connection from the pool."""
        connection = self.pool.get()
        connection.refresh_if_needed()
        return connection

    def return_connection(self, connection: EmailConnectionManager) -> None:
        """Return a connection to the pool."""
        connection.last_used = DatetimeUtils.get_datetime_now()
        self.pool.put(connection)

archipy.adapters.email.adapters.EmailConnectionPool.config instance-attribute

config = config

archipy.adapters.email.adapters.EmailConnectionPool.pool instance-attribute

pool: Queue[EmailConnectionManager] = Queue(
    maxsize=config.POOL_SIZE
)

archipy.adapters.email.adapters.EmailConnectionPool.get_connection

get_connection() -> EmailConnectionManager

Get a connection from the pool.

Source code in archipy/adapters/email/adapters.py
def get_connection(self) -> EmailConnectionManager:
    """Get a connection from the pool."""
    connection = self.pool.get()
    connection.refresh_if_needed()
    return connection

archipy.adapters.email.adapters.EmailConnectionPool.return_connection

return_connection(
    connection: EmailConnectionManager,
) -> None

Return a connection to the pool.

Source code in archipy/adapters/email/adapters.py
def return_connection(self, connection: EmailConnectionManager) -> None:
    """Return a connection to the pool."""
    connection.last_used = DatetimeUtils.get_datetime_now()
    self.pool.put(connection)

archipy.adapters.email.adapters.AttachmentHandler

Enhanced attachment handler with better type safety and validation.

Source code in archipy/adapters/email/adapters.py
class AttachmentHandler:
    """Enhanced attachment handler with better type safety and validation."""

    @staticmethod
    def create_attachment(
        source: str | bytes | BinaryIO | HttpUrl,
        filename: str,
        attachment_type: EmailAttachmentType,
        content_type: str | None = None,
        content_disposition: EmailAttachmentDispositionType = EmailAttachmentDispositionType.ATTACHMENT,
        content_id: str | None = None,
        max_size: int | None = None,
    ) -> EmailAttachmentDTO:
        """Create an attachment with validation.

        Args:
            source: Attachment content, file path, base64 string, URL or binary stream.
            filename: Name shown to the recipient.
            attachment_type: How ``source`` must be interpreted.
            content_type: Optional MIME type of the attachment.
            content_disposition: Whether the attachment is inline or a regular attachment.
            content_id: Optional Content-ID for inline attachments.
            max_size: Maximum size in bytes; defaults to the configured ``ATTACHMENT_MAX_SIZE``.

        Returns:
            EmailAttachmentDTO: The validated attachment.

        Raises:
            InvalidArgumentError: If the source cannot be read or the attachment fails validation
                (for example it exceeds the maximum size).
        """
        if max_size is None:
            max_size = BaseConfig.global_config().EMAIL.ATTACHMENT_MAX_SIZE
        try:
            processed_content = AttachmentHandler._process_source(source, attachment_type)

            return EmailAttachmentDTO(
                content=processed_content,
                filename=filename,
                content_type=content_type,
                content_disposition=content_disposition,
                content_id=content_id,
                attachment_type=attachment_type,
                max_size=max_size,
            )
        except (smtplib.SMTPException, OSError, TimeoutError) as exception:
            raise InvalidArgumentError(
                argument_name="attachment",
                additional_data={"reason": "create_failed", "detail": str(exception)},
            ) from exception
        except ValidationError as exception:
            raise InvalidArgumentError(
                argument_name="attachment",
                additional_data={"reason": "validation_failed", "detail": str(exception)},
            ) from exception

    @staticmethod
    def _process_file_source(source: str | bytes | BinaryIO | HttpUrl) -> bytes:
        """Read bytes from a filesystem path attachment source."""
        if isinstance(source, str):
            return Path(source).read_bytes()
        if isinstance(source, os.PathLike):
            path_str: str = os.fspath(source)
            return Path(path_str).read_bytes()
        raise InvalidArgumentError(
            argument_name="source",
            additional_data={"attachment_type": "file", "got": type(source).__name__},
        )

    @staticmethod
    def _process_base64_source(source: str | bytes | BinaryIO | HttpUrl) -> bytes:
        """Decode a base64 attachment source."""
        if isinstance(source, str | bytes):
            return base64.b64decode(source)
        raise InvalidArgumentError(
            argument_name="source",
            additional_data={"attachment_type": "base64", "got": type(source).__name__},
        )

    @staticmethod
    @trace_span(name="email.fetch_attachment_url")
    def _process_url_source(source: str | bytes | BinaryIO | HttpUrl) -> bytes:
        """Fetch bytes from a URL attachment source."""
        if isinstance(source, str | HttpUrl):
            response = httpx2.get(str(source), timeout=30)
            response.raise_for_status()
            return bytes(response.content)
        raise InvalidArgumentError(
            argument_name="source",
            additional_data={"attachment_type": "url", "got": type(source).__name__},
        )

    @staticmethod
    def _read_binary_content(source: object) -> bytes:
        """Read bytes from a binary-like object with a ``read`` method."""
        if isinstance(source, bytes):
            return source
        if isinstance(source, BinaryIO):
            return source.read()
        read = getattr(source, "read", None)
        if callable(read):
            result = cast("Callable[[], object]", read)()
            if isinstance(result, bytes):
                return result
            if isinstance(result, str):
                return result.encode("utf-8")
            raise InvalidArgumentError(
                argument_name="read_result",
                additional_data={"got": type(result).__name__},
            )
        raise InvalidArgumentError(
            argument_name="source",
            additional_data={"got": type(source).__name__},
        )

    @staticmethod
    def _process_source(source: str | bytes | BinaryIO | HttpUrl, attachment_type: EmailAttachmentType) -> bytes:
        """Process different types of attachment sources."""
        processors = {
            EmailAttachmentType.FILE: AttachmentHandler._process_file_source,
            EmailAttachmentType.BASE64: AttachmentHandler._process_base64_source,
            EmailAttachmentType.URL: AttachmentHandler._process_url_source,
            EmailAttachmentType.BINARY: AttachmentHandler._read_binary_content,
        }
        processor = processors.get(attachment_type)
        if processor is None:
            raise InvalidArgumentError(
                argument_name="attachment_type",
                additional_data={"got": str(attachment_type)},
            )
        return processor(source)

    @staticmethod
    def process_attachment(msg: MIMEMultipart, attachment: EmailAttachmentDTO) -> None:
        """Process and attach the attachment to the email message."""
        content = AttachmentHandler._get_content(attachment)
        part = AttachmentHandler._create_mime_part(content, attachment)

        # Add headers
        part.add_header("Content-Disposition", attachment.content_disposition.value, filename=attachment.filename)

        if attachment.content_id:
            part.add_header("Content-ID", attachment.content_id)

        msg.attach(part)

    @staticmethod
    def _get_content(attachment: EmailAttachmentDTO) -> bytes:
        """Get content as bytes from attachment."""
        if isinstance(attachment.content, str | bytes):
            return attachment.content if isinstance(attachment.content, bytes) else attachment.content.encode()
        return attachment.content.read()

    @staticmethod
    def _create_mime_part(
        content: bytes,
        attachment: EmailAttachmentDTO,
    ) -> MIMEText | MIMEImage | MIMEAudio | MIMEBase:
        """Create appropriate MIME part based on content type."""
        if not attachment.content_type:
            raise InvalidArgumentError(argument_name="content_type")
        main_type, sub_type = attachment.content_type.split("/", 1)

        if main_type == "text":
            return MIMEText(content.decode(), sub_type)
        if main_type == "image":
            return MIMEImage(content, _subtype=sub_type)
        if main_type == "audio":
            return MIMEAudio(content, _subtype=sub_type)
        part = MIMEBase(main_type, sub_type)
        part.set_payload(content)
        encoders.encode_base64(part)
        return part

archipy.adapters.email.adapters.AttachmentHandler.create_attachment staticmethod

create_attachment(
    source: str | bytes | BinaryIO | HttpUrl,
    filename: str,
    attachment_type: EmailAttachmentType,
    content_type: str | None = None,
    content_disposition: EmailAttachmentDispositionType = EmailAttachmentDispositionType.ATTACHMENT,
    content_id: str | None = None,
    max_size: int | None = None,
) -> EmailAttachmentDTO

Create an attachment with validation.

Parameters:

Name Type Description Default
source str | bytes | BinaryIO | HttpUrl

Attachment content, file path, base64 string, URL or binary stream.

required
filename str

Name shown to the recipient.

required
attachment_type EmailAttachmentType

How source must be interpreted.

required
content_type str | None

Optional MIME type of the attachment.

None
content_disposition EmailAttachmentDispositionType

Whether the attachment is inline or a regular attachment.

ATTACHMENT
content_id str | None

Optional Content-ID for inline attachments.

None
max_size int | None

Maximum size in bytes; defaults to the configured ATTACHMENT_MAX_SIZE.

None

Returns:

Name Type Description
EmailAttachmentDTO EmailAttachmentDTO

The validated attachment.

Raises:

Type Description
InvalidArgumentError

If the source cannot be read or the attachment fails validation (for example it exceeds the maximum size).

Source code in archipy/adapters/email/adapters.py
@staticmethod
def create_attachment(
    source: str | bytes | BinaryIO | HttpUrl,
    filename: str,
    attachment_type: EmailAttachmentType,
    content_type: str | None = None,
    content_disposition: EmailAttachmentDispositionType = EmailAttachmentDispositionType.ATTACHMENT,
    content_id: str | None = None,
    max_size: int | None = None,
) -> EmailAttachmentDTO:
    """Create an attachment with validation.

    Args:
        source: Attachment content, file path, base64 string, URL or binary stream.
        filename: Name shown to the recipient.
        attachment_type: How ``source`` must be interpreted.
        content_type: Optional MIME type of the attachment.
        content_disposition: Whether the attachment is inline or a regular attachment.
        content_id: Optional Content-ID for inline attachments.
        max_size: Maximum size in bytes; defaults to the configured ``ATTACHMENT_MAX_SIZE``.

    Returns:
        EmailAttachmentDTO: The validated attachment.

    Raises:
        InvalidArgumentError: If the source cannot be read or the attachment fails validation
            (for example it exceeds the maximum size).
    """
    if max_size is None:
        max_size = BaseConfig.global_config().EMAIL.ATTACHMENT_MAX_SIZE
    try:
        processed_content = AttachmentHandler._process_source(source, attachment_type)

        return EmailAttachmentDTO(
            content=processed_content,
            filename=filename,
            content_type=content_type,
            content_disposition=content_disposition,
            content_id=content_id,
            attachment_type=attachment_type,
            max_size=max_size,
        )
    except (smtplib.SMTPException, OSError, TimeoutError) as exception:
        raise InvalidArgumentError(
            argument_name="attachment",
            additional_data={"reason": "create_failed", "detail": str(exception)},
        ) from exception
    except ValidationError as exception:
        raise InvalidArgumentError(
            argument_name="attachment",
            additional_data={"reason": "validation_failed", "detail": str(exception)},
        ) from exception

archipy.adapters.email.adapters.AttachmentHandler.process_attachment staticmethod

process_attachment(
    msg: MIMEMultipart, attachment: EmailAttachmentDTO
) -> None

Process and attach the attachment to the email message.

Source code in archipy/adapters/email/adapters.py
@staticmethod
def process_attachment(msg: MIMEMultipart, attachment: EmailAttachmentDTO) -> None:
    """Process and attach the attachment to the email message."""
    content = AttachmentHandler._get_content(attachment)
    part = AttachmentHandler._create_mime_part(content, attachment)

    # Add headers
    part.add_header("Content-Disposition", attachment.content_disposition.value, filename=attachment.filename)

    if attachment.content_id:
        part.add_header("Content-ID", attachment.content_id)

    msg.attach(part)

archipy.adapters.email.adapters.EmailAdapter

Bases: EmailPort

Email adapter implementing EmailPort for sending emails with SMTP.

Source code in archipy/adapters/email/adapters.py
class EmailAdapter(EmailPort):
    """Email adapter implementing EmailPort for sending emails with SMTP."""

    def __init__(self, config: EmailConfig | None = None) -> None:
        """Initialize EmailAdapter."""
        self.config = config or BaseConfig.global_config().EMAIL
        self.connection_pool = EmailConnectionPool(self.config)

    @override
    def send_email(
        self,
        to_email: EmailStr | list[EmailStr],
        subject: str,
        body: str,
        cc: EmailStr | list[EmailStr] | None = None,
        bcc: EmailStr | list[EmailStr] | None = None,
        attachments: list[str | EmailAttachmentDTO] | None = None,
        html: bool = False,
        template: str | None = None,
        template_vars: dict[str, Any] | None = None,
    ) -> None:
        """Send email with advanced features and connection pooling.

        Args:
            to_email: Primary recipient(s) of the email.
            subject: Email subject line.
            body: Email body content (plain text or HTML).
            cc: Carbon copy recipient(s).
            bcc: Blind carbon copy recipient(s).
            attachments: List of file paths or EmailAttachmentDTO objects.
            html: If True, treats body as HTML content, otherwise plain text.
            template: A template string to render using template_vars.
            template_vars: Variables to use when rendering the template.

        Raises:
            InvalidArgumentError: If no SMTP server is configured or an attachment is invalid.
            UnavailableError: If the SMTP server cannot be reached or the email cannot be sent
                after ``MAX_RETRIES`` attempts.
        """
        self._send_email_traced(
            to_email=to_email,
            subject=subject,
            body=body,
            cc=cc,
            bcc=bcc,
            attachments=attachments,
            html=html,
            template=template,
            template_vars=template_vars,
        )

    @trace_span(name="email.send", kind=_smtp_span_kind(), attributes={"network.protocol.name": "smtp"})
    def _send_email_traced(
        self,
        to_email: EmailStr | list[EmailStr],
        subject: str,
        body: str,
        cc: EmailStr | list[EmailStr] | None = None,
        bcc: EmailStr | list[EmailStr] | None = None,
        attachments: list[str | EmailAttachmentDTO] | None = None,
        html: bool = False,
        template: str | None = None,
        template_vars: dict[str, Any] | None = None,
    ) -> None:
        """Send the email inside the ``email.send`` span (SMTP client span without any message content)."""
        _annotate_current_span(
            {
                "server.address": self.config.SMTP_SERVER or "",
                "server.port": self.config.SMTP_PORT,
                "email.recipient_count": len(self._get_all_recipients(to_email, cc, bcc)),
                "email.attachment_count": len(attachments or []),
            },
        )
        connection: EmailConnectionManager | None = None
        try:
            connection = self.connection_pool.get_connection()
            msg = self._create_message(
                to_email=to_email,
                subject=subject,
                body=body,
                cc=cc,
                bcc=bcc,
                attachments=attachments,
                html=html,
                template=template,
                template_vars=template_vars,
            )

            recipients = self._get_all_recipients(to_email, cc, bcc)

            last_error: Exception | None = None
            for _ in range(self.config.MAX_RETRIES):
                try:
                    if not connection.smtp_connection:
                        connection.connect()
                    if connection.smtp_connection:
                        connection.smtp_connection.send_message(msg, to_addrs=recipients)
                        logger.debug("Email sent successfully to %s", to_email)
                        return
                    last_error = connection.last_error
                except (smtplib.SMTPException, OSError, TimeoutError) as e:
                    BaseUtils.capture_exception(e)
                    last_error = e
                    connection.disconnect()  # Close the broken session; reconnect on next attempt

            raise UnavailableError(resource_type="SMTP") from last_error
        except (smtplib.SMTPException, OSError, TimeoutError) as e:
            BaseUtils.capture_exception(e)
            raise UnavailableError(resource_type="SMTP") from e
        finally:
            if connection:
                self.connection_pool.return_connection(connection)

    def _create_message(
        self,
        to_email: EmailStr | list[EmailStr],
        subject: str,
        body: str,
        cc: EmailStr | list[EmailStr] | None = None,
        bcc: EmailStr | list[EmailStr] | None = None,
        attachments: list[str | EmailAttachmentDTO] | None = None,
        html: bool = False,
        template: str | None = None,
        template_vars: dict[str, Any] | None = None,
    ) -> MIMEMultipart:
        msg = MIMEMultipart()
        msg["From"] = self.config.USERNAME or "no-reply@example.com"
        msg["To"] = to_email if isinstance(to_email, str) else ", ".join(to_email)
        msg["Subject"] = subject

        if cc:
            msg["Cc"] = cc if isinstance(cc, str) else ", ".join(cc)
        if bcc:
            msg["Bcc"] = bcc if isinstance(bcc, str) else ", ".join(bcc)

        if template:
            try:
                from jinja2 import Template
            except ImportError as exception:
                raise ConfigurationError(
                    operation="email_template",
                    reason="jinja2 is not installed; install it to render email templates",
                ) from exception
            body = Template(template).render(**(template_vars or {}))

        msg.attach(MIMEText(body, "html" if html else "plain"))

        if attachments:
            for attachment in attachments:
                if isinstance(attachment, str):
                    # Treat as file path
                    attachment_obj = AttachmentHandler.create_attachment(
                        source=attachment,
                        filename=Path(attachment).name,
                        attachment_type=EmailAttachmentType.FILE,
                    )
                else:
                    attachment_obj = attachment
                AttachmentHandler.process_attachment(msg, attachment_obj)

        return msg

    @staticmethod
    def _get_all_recipients(
        to_email: EmailStr | list[EmailStr],
        cc: EmailStr | list[EmailStr] | None,
        bcc: EmailStr | list[EmailStr] | None,
    ) -> list[str]:
        """Get list of all recipients."""
        recipients = []

        # Add primary recipients
        if isinstance(to_email, str):
            recipients.append(to_email)
        else:
            recipients.extend(to_email)

        # Add CC recipients
        if cc:
            if isinstance(cc, str):
                recipients.append(cc)
            else:
                recipients.extend(cc)

        # Add BCC recipients
        if bcc:
            if isinstance(bcc, str):
                recipients.append(bcc)
            else:
                recipients.extend(bcc)

        return recipients

archipy.adapters.email.adapters.EmailAdapter.config instance-attribute

config = config or BaseConfig.global_config().EMAIL

archipy.adapters.email.adapters.EmailAdapter.connection_pool instance-attribute

connection_pool = EmailConnectionPool(self.config)

archipy.adapters.email.adapters.EmailAdapter.send_email

send_email(
    to_email: EmailStr | list[EmailStr],
    subject: str,
    body: str,
    cc: EmailStr | list[EmailStr] | None = None,
    bcc: EmailStr | list[EmailStr] | None = None,
    attachments: list[str | EmailAttachmentDTO]
    | None = None,
    html: bool = False,
    template: str | None = None,
    template_vars: dict[str, Any] | None = None,
) -> None

Send email with advanced features and connection pooling.

Parameters:

Name Type Description Default
to_email EmailStr | list[EmailStr]

Primary recipient(s) of the email.

required
subject str

Email subject line.

required
body str

Email body content (plain text or HTML).

required
cc EmailStr | list[EmailStr] | None

Carbon copy recipient(s).

None
bcc EmailStr | list[EmailStr] | None

Blind carbon copy recipient(s).

None
attachments list[str | EmailAttachmentDTO] | None

List of file paths or EmailAttachmentDTO objects.

None
html bool

If True, treats body as HTML content, otherwise plain text.

False
template str | None

A template string to render using template_vars.

None
template_vars dict[str, Any] | None

Variables to use when rendering the template.

None

Raises:

Type Description
InvalidArgumentError

If no SMTP server is configured or an attachment is invalid.

UnavailableError

If the SMTP server cannot be reached or the email cannot be sent after MAX_RETRIES attempts.

Source code in archipy/adapters/email/adapters.py
@override
def send_email(
    self,
    to_email: EmailStr | list[EmailStr],
    subject: str,
    body: str,
    cc: EmailStr | list[EmailStr] | None = None,
    bcc: EmailStr | list[EmailStr] | None = None,
    attachments: list[str | EmailAttachmentDTO] | None = None,
    html: bool = False,
    template: str | None = None,
    template_vars: dict[str, Any] | None = None,
) -> None:
    """Send email with advanced features and connection pooling.

    Args:
        to_email: Primary recipient(s) of the email.
        subject: Email subject line.
        body: Email body content (plain text or HTML).
        cc: Carbon copy recipient(s).
        bcc: Blind carbon copy recipient(s).
        attachments: List of file paths or EmailAttachmentDTO objects.
        html: If True, treats body as HTML content, otherwise plain text.
        template: A template string to render using template_vars.
        template_vars: Variables to use when rendering the template.

    Raises:
        InvalidArgumentError: If no SMTP server is configured or an attachment is invalid.
        UnavailableError: If the SMTP server cannot be reached or the email cannot be sent
            after ``MAX_RETRIES`` attempts.
    """
    self._send_email_traced(
        to_email=to_email,
        subject=subject,
        body=body,
        cc=cc,
        bcc=bcc,
        attachments=attachments,
        html=html,
        template=template,
        template_vars=template_vars,
    )

options: show_root_toc_entry: false heading_level: 3