sipa/mail.py

Summary

Maintainability
B
4 hrs
Test Coverage
"""
Functions concerned with mail composition and transmission

This module ultimately provides functions to be used by a blueprint in
order to compose and send a mail, for instance
:py:func:`send_contact_mail`.

On the layer below, some intermediate functions are introduced which
are needed to compose and send the mails.  The core is
:py:func:`send_complex_mail`, which calls :py:func:`send_mail`
prepending optional information to the title and body
"""

import logging
import smtplib
import ssl
import textwrap
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from typing import Any

from flask import current_app
from flask_login import current_user

from sipa.backends.extension import backends
from sipa.model.user import BaseUser

logger = logging.getLogger(__name__)


def wrap_message(message: str, chars_in_line: int = 80) -> str:
    """Wrap a block of text to a certain amount of characters

    :param message:
    :param chars_in_line: The width to wrap against

    :returns: the wrapped message
    """
    return_text = []
    for paragraph in message.split('\n'):
        lines = textwrap.wrap(paragraph, chars_in_line)
        if not lines:
            return_text.append('')
        else:
            return_text.extend(lines)
    return '\n'.join(return_text)


def send_mail(author: str, recipient: str, subject: str, message: str,
              reply_to: str = None) -> bool:
    """Send a MIME text mail

    Send a mail from ``author`` to ``receipient`` with ``subject`` and
    ``message``.  The message will be wrapped to 80 characters and
    encoded to UTF8.

    Returns False, if sending from localhost:25 fails.  Else returns
    True.

    :param author: The mail address of the author
    :param recipient: The mail address of the recipient
    :param subject:
    :param message:
    :param reply_to:

    :returns: Whether the transmission succeeded
    """
    sender = current_app.config['CONTACT_SENDER_MAIL']
    message = wrap_message(message)
    mail = MIMEText(message, _charset='utf-8')

    mail['Message-Id'] = make_msgid()
    mail['From'] = author
    mail['Reply-To'] = author if reply_to is None else reply_to
    mail['X-OTRS-CustomerId'] = author
    mail['To'] = recipient
    mail['Subject'] = subject
    mail['Date'] = formatdate(localtime=True)

    mailserver_host = current_app.config['MAILSERVER_HOST']
    mailserver_port = current_app.config['MAILSERVER_PORT']
    mailserver_user = current_app.config['MAILSERVER_USER']
    mailserver_password = current_app.config['MAILSERVER_PASSWORD']

    mailserver_ssl = current_app.config['MAILSERVER_SSL']
    use_ssl = mailserver_ssl == 'ssl'
    use_starttls = mailserver_ssl == 'starttls'

    if use_ssl or use_starttls:
        try:
            ssl_context = ssl.create_default_context(
                cafile=current_app.config['MAILSERVER_SSL_CA_FILE'],
                cadata=current_app.config['MAILSERVER_SSL_CA_DATA'])

            if current_app.config['MAILSERVER_SSL_VERIFY']:
                ssl_context.verify_mode = ssl.VerifyMode.CERT_REQUIRED
                ssl_context.check_hostname = True
            else:
                ssl_context.check_hostname = False
                ssl_context.verify_mode = ssl.VerifyMode.CERT_NONE
        except ssl.SSLError as e:
            # smtp.connect failed to connect
            logger.critical('Unable to create ssl context', extra={
                'trace': True,
                'data': {'exception_arguments': e.args}
            })
            return False

    try:
        if use_ssl:
            smtp = smtplib.SMTP_SSL(host=mailserver_host, port=mailserver_port,
                                    context=ssl_context)
        else:
            smtp = smtplib.SMTP(host=mailserver_host, port=mailserver_port)

        if use_starttls:
            smtp.starttls(context=ssl_context)

        if mailserver_user:
            smtp.login(mailserver_user, mailserver_password)

        smtp.sendmail(from_addr=sender, to_addrs=recipient, msg=mail.as_string())
        smtp.close()
    except OSError as e:
        # smtp.connect failed to connect
        logger.critical('Unable to connect to SMTP server', extra={
            'trace': True,
            'tags': {'mailserver': f"{mailserver_host}:{mailserver_port}"},
            'data': {'exception_arguments': e.args}
        })
        return False
    else:
        logger.info('Successfully sent mail from usersuite', extra={
            'tags': {'from': author, 'to': recipient,
                     'mailserver': f"{mailserver_host}:{mailserver_port}"},
            'data': {'subject': subject, 'message': message}
        })
        return True


def send_contact_mail(author: str, subject: str, message: str,
                      name: str, dormitory_name: str) -> bool:
    """Compose a mail for anonymous contacting.

    Call :py:func:`send_complex_mail` setting a tag plus name and
    dormitory in the header.

    :param author: The e-mail of the author
    :param subject:
    :param message:
    :param name: The author's real-life name
    :param dormitory_name: The string identifier of the chosen dormitory

    :returns: see :py:func:`send_complex_mail`
    """
    dormitory = backends.get_dormitory(dormitory_name)

    return send_complex_mail(
        author=author,
        recipient=backends.datasource.support_mail,
        subject=subject,
        message=message,
        tag="Kontakt",
        header={'Name': name, 'Dormitory': dormitory.display_name},
    )


def send_official_contact_mail(author: str, subject: str, message: str,
                               name: str) -> bool:
    """Compose a mail for official contacting.

    Call :py:func:`send_complex_mail` setting a tag.

    :param author: The e-mail address of the author
    :param subject:
    :param message:
    :param name: The author's real-life name

    :returns: see :py:func:`send_complex_mail`
    """
    return send_complex_mail(
        author=author,
        recipient="vorstand@lists.agdsn.de",
        subject=subject,
        message=message,
        tag="Kontakt",
        header={'Name': name},
    )


def send_usersuite_contact_mail(subject: str, message: str, category: str,
                                user: BaseUser = current_user,
                                author: str = None) -> bool:
    """Compose a mail for contacting from the usersuite

    Call :py:func:`send_complex_mail` setting a tag and the category
    plus the user's login in the header.

    The author is chosen to be the user's mailaccount on the
    datasource's mail server.

    :param subject:
    :param message:
    :param category: The Category as to be included in the title
    :param user:
    :param author: Alternative e-mail address

    :returns: see :py:func:`send_complex_mail`
    """
    return send_complex_mail(
        author=f"{user.login.value}@{user.datasource.mail_server}",
        recipient=user.datasource.support_mail,
        subject=subject,
        message=message,
        tag="Usersuite",
        category=category,
        header={'Login': user.login.value},
        reply_to=author,
    )


def send_complex_mail(subject: str, message: str, tag: str = "",
                      category: str = "", header: dict[str, Any] | None = None,
                      **kwargs) -> bool:
    """Send a mail with context information in subject and body.

    This function is just a modified call of :py:func:`send_mail`, to
    which all the other arguments are passed.

    :param subject:
    :param message:
    :param tag: See :py:func:`compose_subject`
    :param category: See :py:func:`compose_subject`
    :param header: See :py:func:`compose_body`

    :returns: see :py:func:`send_mail`
    """
    return send_mail(
        **kwargs,
        subject=compose_subject(subject, tag=tag, category=category),
        message=compose_body(message, header=header),
    )


def compose_subject(raw_subject: str, tag: str = "", category: str = "") -> str:
    """Compose a subject containing a tag and a category.

    If any of tag or category is missing, don't print the
    corresponding part (without whitespace issues).

    :param raw_subject: The original subject
    :param tag:
    :param category:

    :returns: The subject.  Form: "[{tag}] {category}: {raw_subject}"
    """
    subject = ""
    if tag:
        subject += f"[{tag}] "

    if category:
        subject += f"{category}: "

    subject += raw_subject

    return subject


def compose_body(message: str, header: dict[str, Any] | None = None):
    """Prepend additional information to a message.

    :param message:
    :param header: Dict of the additional "key: value"
        entries to be prepended to the mail.

    :returns: The composed body
    """
    if not header:
        return message

    serialized_header = "\n".join(f"{k}: {v}" for k, v in header.items())

    return f"{serialized_header}\n\n{message}"