import os
import smtplib
from datetime import datetime
from typing import TYPE_CHECKING
from mako.template import Template
from pyramid.settings import asbool
from magpie.constants import get_constant
from magpie.utils import get_logger, get_magpie_url, get_settings, raise_log
if TYPE_CHECKING:
from typing import Any, Dict, Optional, Union
from magpie.typedefs import AnySettingsContainer, SettingsType, Str, TypedDict
[docs]
SMTPServerConfiguration = TypedDict("SMTPServerConfiguration", {
"from": Str, "host": Str, "port": Str, "user": Str, "password": Optional[Str], "sender": Str, "ssl": bool,
})
TemplateParameters = Dict[Str, Any]
[docs]
LOGGER = get_logger(__name__)
[docs]
TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
[docs]
DEFAULT_TEMPLATE_MAPPING = {
"MAGPIE_GROUP_TERMS_APPROVED_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_group_terms_approved.mako"),
"MAGPIE_GROUP_TERMS_SUBMISSION_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_group_terms_submission.mako"),
"MAGPIE_USER_REGISTRATION_SUBMISSION_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_user_registration_submission.mako"),
"MAGPIE_USER_REGISTRATION_APPROVAL_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_user_registration_approval.mako"),
"MAGPIE_USER_REGISTRATION_APPROVED_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_user_registration_approved.mako"),
"MAGPIE_USER_REGISTRATION_DECLINED_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_user_registration_declined.mako"),
"MAGPIE_USER_REGISTRATION_NOTIFY_EMAIL_TEMPLATE":
os.path.join(TEMPLATE_DIR, "email_user_registration_notify.mako"),
}
[docs]
def get_email_template(template_constant, container=None):
# type: (Str, Optional[AnySettingsContainer]) -> Template
"""
Retrieves the template file with email content matching the custom application setting or the corresponding default.
Allowed values of :paramref:`template_constant` are:
- :envvar:`MAGPIE_GROUP_TERMS_APPROVED_EMAIL_TEMPLATE`
- :envvar:`MAGPIE_GROUP_TERMS_SUBMISSION_EMAIL_TEMPLATE`
- :envvar:`MAGPIE_USER_REGISTRATION_SUBMISSION_EMAIL_TEMPLATE`
- :envvar:`MAGPIE_USER_REGISTRATION_APPROVAL_EMAIL_TEMPLATE`
- :envvar:`MAGPIE_USER_REGISTRATION_APPROVED_EMAIL_TEMPLATE`
- :envvar:`MAGPIE_USER_REGISTRATION_DECLINED_EMAIL_TEMPLATE`
- :envvar:`MAGPIE_USER_REGISTRATION_NOTIFY_EMAIL_TEMPLATE`
:raises IOError: if an explicit override value of the requested template cannot be located.
:returns: template formatter from the requested template file.
"""
if template_constant not in DEFAULT_TEMPLATE_MAPPING:
raise_log("Specified template is not one of {}".format(list(DEFAULT_TEMPLATE_MAPPING)), ValueError, LOGGER)
template_file = get_constant(template_constant, container,
default_value=DEFAULT_TEMPLATE_MAPPING[template_constant],
print_missing=False, empty_missing=True, raise_missing=False, raise_not_set=False)
if not isinstance(template_file, str) or not os.path.isfile(template_file) or not template_file.endswith(".mako"):
raise_log("Email template [{}] missing or invalid from [{!s}]".format(template_constant, template_file),
IOError, logger=LOGGER)
filters = [
"decode.utf8", # email expected with Content-Type charset=UTF-8
"trim",
"h", # apply HTML escape for security
]
template = Template(filename=template_file, # nosec: B702 # mako escapes against XSS attacks
default_filters=filters,
strict_undefined=True) # report name of any missing variable reference
return template
[docs]
def get_smtp_server_configuration(settings):
# type: (SettingsType) -> SMTPServerConfiguration
"""
Obtains and validates all required configuration parameters for SMTP server in order to send an email.
"""
from_user = get_constant("MAGPIE_SMTP_USER", settings, default_value="Magpie",
print_missing=False, raise_missing=False, raise_not_set=False)
from_addr = get_constant("MAGPIE_SMTP_FROM", settings,
print_missing=True, raise_missing=False, raise_not_set=False)
password = get_constant("MAGPIE_SMTP_PASSWORD", settings,
print_missing=True, raise_missing=False, raise_not_set=False)
smtp_host = get_constant("MAGPIE_SMTP_HOST", settings, empty_missing=True, print_missing=True)
smtp_port = get_constant("MAGPIE_SMTP_PORT", settings,
raise_not_set=False, raise_missing=False, default_value=465)
smtp_ssl = get_constant("MAGPIE_SMTP_SSL", settings, default_value=True,
print_missing=True, raise_missing=False, raise_not_set=False)
# one of [from-user/from-addr] must be provided to define the 'sender' (FROM email field)
# - addr has priority over user as it is unique compared to display name, but both are acceptable
# - user takes value of addr to display that instead of blank user name explicitly overridden as empty string
# - addr can then take the value of user name if addr was omitted
# (valid only when no auth with password required, or "user" was defined as email directly instead of "from")
sender = from_addr or from_user
from_user = from_user or from_addr
from_addr = from_addr or from_user
config = {
"from": from_addr,
"host": smtp_host,
"port": smtp_port,
"user": from_user,
"password": password,
"sender": sender,
"ssl": asbool(smtp_ssl),
}
# host, port and resolved sender must always be defined regardless of direct, resolved or default values
if not smtp_host or not str.isnumeric(str(smtp_port)) or not sender:
LOGGER.debug("SMTP invalid config: %s", config)
raise ValueError("SMTP email server configuration is missing required parameters.")
config["port"] = int(config["port"]) # update only after validated
return config
[docs]
def get_smtp_server_connection(config):
# type: (SMTPServerConfiguration) -> Union[smtplib.SMTP, smtplib.SMTP_SSL]
"""
Obtains an opened connection to a SMTP server from application settings.
If the connection is correctly instantiated, the returned SMTP server will be ready for sending emails.
"""
if config["ssl"]:
server = smtplib.SMTP_SSL(config["host"], config["port"])
else:
server = smtplib.SMTP(config["host"], config["port"])
server.ehlo()
try:
server.starttls()
server.ehlo()
except smtplib.SMTPException:
LOGGER.warning("[Security Risk] "
"Failed to establish a TLS connection to SMTP server when SSL was explicitly disabled. "
"Emails will not be encrypted.")
if config["password"]:
server.login(config["from"], config["password"])
return server
[docs]
def make_email_contents(config, settings, template, parameters=None):
# type: (SMTPServerConfiguration, SettingsType, Template, Optional[TemplateParameters]) -> Str
"""
Generates the email contents using the template, substitution parameters, and the target email server configuration.
"""
# add defaults parameters always offered to all templates
magpie_url = get_magpie_url(settings)
iso_dt = datetime.utcnow().isoformat(" ") # "YYYY-MM-DD HH:mm:ss.ffffff UTC"
iso_dt = iso_dt.split(".")[0]
params = {
"magpie_url": magpie_url,
"login_url": "{}/ui/login".format(magpie_url),
"email_sender": config["sender"],
"email_user": config["user"],
"email_from": config["from"],
"email_datetime": iso_dt
}
params.update(parameters or {})
contents = template.render(**params)
message = "{}".format(contents).strip("\n")
return message.encode("utf8")
[docs]
def send_email(recipient, container, template, parameters=None):
# type: (Str, AnySettingsContainer, Template, Optional[TemplateParameters]) -> bool
"""
Send email notification using provided template and parameters.
The preparation steps of the email (retrieve SMTP configuration, setup the SMTP connection, define email content
parameters and attempt template generation) will directly raise if invalid as they correspond to incorrect
application code or configuration settings.
Following step to send the email with the established SMTP connection is caught and logged if raising an exception.
This is to allow the calling operation to ignore failing email notification and act accordingly using the resulting
email status.
:param recipient: Email address of the intended recipient to which the email must be sent.
:param template: Mako template used for the email contents.
:param container: Any container to retrieve application settings.
:param parameters:
Parameters to provide for templating email contents.
They are applied on top of various defaults values provided to all emails.
:raises: any SMTP server configuration, template generator or parameter parsing error for email setup.
:returns: success status of the notification email (sent without error, no guarantee of reception).
"""
LOGGER.debug("Preparing email to: [%s] using template [%s]", recipient, template.filename)
if not recipient:
LOGGER.warning("Skipping send email request without a valid recipient: [%s]", template.filename)
return False
settings = get_settings(container)
config = get_smtp_server_configuration(settings)
params = parameters or {}
params["email_recipient"] = recipient
message = make_email_contents(config, settings, template, params)
LOGGER.debug("Creating SMTP connection to send email using config: %s.", config)
server = get_smtp_server_connection(config)
result = None
try:
LOGGER.debug("Sending email to: [%s] using template [%s]", recipient, template.filename)
# result of sendmail is returned only if at least one of many recipients succeeds,
# but here we use just one, so it should either succeed completely or raise
result = server.sendmail(config["sender"], [recipient], message)
except Exception as exc:
LOGGER.error("Failure during notification email to: [%s] using template [%s]. "
"Error: %r", recipient, template.filename, exc)
LOGGER.debug("Email contents:\n\n%s\n", message, exc_info=exc)
# don't re-raise here (see docstring)
finally:
server.quit()
if result:
LOGGER.debug("Unexpected error result from SMTP server during email notification:\n%s", result)
return False
LOGGER.debug("Successfully sent email to: [%s] using template [%s]", recipient, template.filename)
return True