Skip to content

Commit

Permalink
Merge branch 'mail_config_extract' into lib_user_explode
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasjuhrich committed Sep 28, 2024
2 parents 634b88e + e1a0e13 commit 12d11e5
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 38 deletions.
143 changes: 105 additions & 38 deletions pycroft/lib/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,35 @@
pycroft.lib.mail
~~~~~~~~~~~~~~~~
"""

from __future__ import annotations
import logging
import os
import smtplib
import ssl
import traceback
import typing as t
from dataclasses import dataclass
from contextvars import ContextVar
from dataclasses import dataclass, field, InitVar
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import make_msgid, formatdate
from functools import lru_cache

import jinja2
from werkzeug.local import LocalProxy

from pycroft.lib.exc import PycroftLibException

mail_envelope_from = os.environ["PYCROFT_MAIL_ENVELOPE_FROM"]
mail_from = os.environ["PYCROFT_MAIL_FROM"]
mail_reply_to = os.environ["PYCROFT_MAIL_REPLY_TO"]
smtp_host = os.environ["PYCROFT_SMTP_HOST"]
smtp_port = int(os.environ.get('PYCROFT_SMTP_PORT', 465))
smtp_user = os.environ["PYCROFT_SMTP_USER"]
smtp_password = os.environ["PYCROFT_SMTP_PASSWORD"]
smtp_ssl = os.environ.get('PYCROFT_SMTP_SSL', 'ssl')
template_path_type = os.environ.get('PYCROFT_TEMPLATE_PATH_TYPE', 'filesystem')
template_path = os.environ.get('PYCROFT_TEMPLATE_PATH', 'pycroft/templates')

# TODO proxy and DI; set at app init
_config_var: ContextVar[MailConfig] = ContextVar("config")
config: MailConfig = LocalProxy(_config_var) # type: ignore[assignment]

logger = logging.getLogger('mail')
logger.setLevel(logging.INFO)

template_loader: jinja2.BaseLoader
if template_path_type == 'filesystem':
template_loader = jinja2.FileSystemLoader(searchpath=f'{template_path}/mail')
else:
template_loader = jinja2.PackageLoader(package_name='pycroft',
package_path=f'{template_path}/mail')

template_env = jinja2.Environment(loader=template_loader)


@dataclass
class Mail:
Expand All @@ -51,6 +41,16 @@ class Mail:
body_html: str | None = None
reply_to: str | None = None

@property
def body_plain_mime(self) -> MIMEText:
return MIMEText(self.body_plain, "plain", _charset="utf-8")

@property
def body_html_mime(self) -> MIMEText | None:
if not self.body_html:
return None
return MIMEText(self.body_html, "html", _charset="utf-8")


class MailTemplate:
template: str
Expand All @@ -61,27 +61,35 @@ def __init__(self, **kwargs: t.Any) -> None:
self.args = kwargs

def render(self, **kwargs: t.Any) -> tuple[str, str]:
plain = template_env.get_template(self.template).render(mode='plain', **self.args, **kwargs)
html = template_env.get_template(self.template).render(mode='html', **self.args, **kwargs)
plain = self.jinja_template.render(mode="plain", **self.args, **kwargs)
html = self.jinja_template.render(mode="html", **self.args, **kwargs)

return plain, html

@property
def jinja_template(self) -> jinja2.Template:
return _get_template(self.template)


@lru_cache(maxsize=None)
def _get_template(template_location: str) -> jinja2.Template:
if config is None:
raise RuntimeError("`mail.config` not set up!")
return config.template_env.get_template(template_location)

def compose_mail(mail: Mail) -> MIMEMultipart:

def compose_mail(mail: Mail, from_: str, default_reply_to: str | None) -> MIMEMultipart:
msg = MIMEMultipart("alternative", _charset="utf-8")
msg["Message-Id"] = make_msgid()
msg["From"] = mail_from
msg["From"] = from_
msg["To"] = str(Header(mail.to_address))
msg["Subject"] = mail.subject
msg["Date"] = formatdate(localtime=True)

msg.attach(MIMEText(mail.body_plain, 'plain', _charset='utf-8'))

if mail.body_html is not None:
msg.attach(MIMEText(mail.body_html, 'html', _charset='utf-8'))

if mail.reply_to is not None or mail.reply_to is not None:
msg['Reply-To'] = mail_reply_to if mail.reply_to is None else mail.reply_to
msg.attach(mail.body_plain_mime)
if (html := mail.body_html_mime) is not None:
msg.attach(html)
if reply_to := mail.reply_to or default_reply_to:
msg["Reply-To"] = reply_to

print(msg)

Expand All @@ -100,12 +108,19 @@ def send_mails(mails: list[Mail]) -> tuple[bool, int]:
:param mails: A list of mails
:returns: Whether the transmission succeeded
:context: config
"""

if not smtp_host:
logger.critical("No mailserver config available")

raise RuntimeError
if config is None:
raise RuntimeError("`mail.config` not set up!")

mail_envelope_from = config.mail_envelope_from
mail_from = config.mail_from
mail_reply_to = config.mail_reply_to
smtp_host = config.smtp_host
smtp_port = config.smtp_port
smtp_user = config.smtp_user
smtp_password = config.smtp_password
smtp_ssl = config.smtp_ssl

use_ssl = smtp_ssl == 'ssl'
use_starttls = smtp_ssl == 'starttls'
Expand Down Expand Up @@ -159,7 +174,7 @@ def send_mails(mails: list[Mail]) -> tuple[bool, int]:

for mail in mails:
try:
mime_mail = compose_mail(mail)
mime_mail = compose_mail(mail, from_=mail_from, default_reply_to=mail_reply_to)
assert mail_envelope_from is not None
smtp.sendmail(from_addr=mail_envelope_from, to_addrs=mail.to_address,
msg=mime_mail.as_string())
Expand Down Expand Up @@ -248,3 +263,55 @@ def send_template_mails(
from pycroft.task import send_mails_async

send_mails_async.delay(mails)


@dataclass
class MailConfig:
mail_envelope_from: str
mail_from: str
mail_reply_to: str | None
smtp_host: str
smtp_user: str
smtp_password: str
smtp_port: int = field(default=465)
smtp_ssl: str = field(default="ssl")

template_path_type: InitVar[str | None] = None
template_path: InitVar[str | None] = None
template_env: jinja2.Environment = field(init=False)

@classmethod
def from_env(cls) -> t.Self:
env = os.environ
config = cls(
mail_envelope_from=env["PYCROFT_MAIL_ENVELOPE_FROM"],
mail_from=env["PYCROFT_MAIL_FROM"],
mail_reply_to=env.get("PYCROFT_MAIL_REPLY_TO"),
smtp_host=env["PYCROFT_SMTP_HOST"],
smtp_user=env["PYCROFT_SMTP_USER"],
smtp_password=env["PYCROFT_SMTP_PASSWORD"],
template_path_type=env.get("PYCROFT_TEMPLATE_PATH_TYPE"),
template_path=env.get("PYCROFT_TEMPLATE_PATH"),
)
if (smtp_port := env.get("PYCROFT_SMTP_PORT")) is not None:
config.smtp_port = int(smtp_port)
if (smtp_ssl := env.get("PYCROFT_SMTP_SSL")) is not None:
config.smtp_ssl = smtp_ssl

return config

def __post_init__(self, template_path_type: str | None, template_path: str | None) -> None:
template_loader: jinja2.BaseLoader
if template_path_type is None:
template_path_type = "filesystem"
if template_path is None:
template_path = "pycroft/templates"

if template_path_type == "filesystem":
template_loader = jinja2.FileSystemLoader(searchpath=f"{template_path}/mail")
else:
template_loader = jinja2.PackageLoader(
package_name="pycroft", package_path=f"{template_path}/mail"
)

self.template_env = jinja2.Environment(loader=template_loader)
3 changes: 3 additions & 0 deletions scripts/server_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import pycroft
import web
from pycroft.helpers.i18n import set_translation_lookup, get_locale
from pycroft.lib.mail import _config_var, MailConfig
from pycroft.model.session import set_scoped_session
from scripts.connection import get_connection_string
from pycroft.model.alembic import determine_schema_state
Expand Down Expand Up @@ -54,6 +55,8 @@ def prepare_server(echo=False, ensure_schema=False) -> PycroftFlask:
)
)
_setup_translations()
_config_var.set(MailConfig.from_env())

if app.config.get("PROFILE", False):
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30])
return app
Expand Down
17 changes: 17 additions & 0 deletions tests/lib/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from sqlalchemy.future import select

from pycroft.model import _all as m
from pycroft.lib.mail import MailConfig, _config_var
from tests import factories


Expand All @@ -22,3 +23,19 @@ def processor(module_session) -> m.User:
@pytest.fixture(scope="module")
def config(module_session) -> m.Config:
return factories.ConfigFactory.create()


@pytest.fixture(scope="module", autouse=True)
def with_mail_config():
token = _config_var.set(
MailConfig(
mail_envelope_from="[email protected]",
mail_from="[email protected]",
mail_reply_to="[email protected]",
smtp_host="agdsn.de",
smtp_user="pycroft",
smtp_password="password",
)
)
yield
_config_var.reset(token)

0 comments on commit 12d11e5

Please sign in to comment.