Add a jinja2 e-mail template for EPN

Add options for character set (default utf8) and message
subtype (default plain). This will allow for more control
for users to do either HTML mail or use ascii for the character
set so the attachment is not base64-encoded to make it easier
for all mail clients.

Collect first and last name as well for each user in order to
provide more options for the template engine.

Make the From address configurable, defaulting to noreply@ipa_domain
Make Subject configurable too.

Don't rely on the MTA to set Message-Id: set it using the email
module.

Fixes: https://pagure.io/freeipa/issue/3687
Signed-off-by: Rob Crittenden <rcritten@redhat.com>
Reviewed-By: Michal Polovka <mpolovka@redhat.com>
Reviewed-By: Christian Heimes <cheimes@redhat.com>
This commit is contained in:
Rob Crittenden 2020-05-12 10:05:50 -04:00 committed by François Cami
parent 3805eff417
commit 03caa7f965
6 changed files with 94 additions and 22 deletions

View File

@ -66,8 +66,19 @@ Specifies the number of seconds to wait for SMTP to respond. Default 60.
.B smtp_security <security> .B smtp_security <security>
Specifies the type of secure connection to make. Options are: none, starttls and ssl. The default is none. Specifies the type of secure connection to make. Options are: none, starttls and ssl. The default is none.
.TP .TP
.B mail_from <address>
Specifies the From: e-mal address value in the e-mails sent. The default is
noreply@ipadefaultemaildomain. This value can be found by running
.I ipa config-show
.TP
.B notify_ttls <list of days> .B notify_ttls <list of days>
This is the list of days before a password expiration when ipa-epn shoould notify a user that their password will soon require a reset. If this value is not specified then the default list will be used: 28, 14, 7, 3, 1. This is the list of days before a password expiration when ipa-epn should notify a user that their password will soon require a reset. If this value is not specified then the default list will be used: 28, 14, 7, 3, 1.
.TP
.B msg_charset <type>
Set the character set of the message. The default is utf8. This will result in he body of the message being base64-encoded.
.TP
.B msg_subtype <type>
Set the message's MIME sub-content type. The default is plain.
.SH "FILES" .SH "FILES"
.TP .TP
.I /etc/ipa/epn.conf .I /etc/ipa/epn.conf

View File

@ -52,6 +52,18 @@ The \fB\-\-dry\-run\fR CLI option is intented to test ipa\-epn's configuration.
For instance, if notify_ttls is set to 21, 14, 3, \fB\-\-dry-run\fR would display the list of users whose passwords would expire in 21, 14, and 3 days in the future. For instance, if notify_ttls is set to 21, 14, 3, \fB\-\-dry-run\fR would display the list of users whose passwords would expire in 21, 14, and 3 days in the future.
.SH "TEMPLATE"
The template for the e\-mail message is contained in /etc/ipa/epn/expire_msg.template. The following template variables are available.
.TP
User ID: uid
.TP
Full name: fullname
.TP
First name: first
.TP
Last name: Last
.TP
Password expiration date: expiration
.SH "EXAMPLES" .SH "EXAMPLES"
.nf .nf

View File

@ -1363,6 +1363,7 @@ fi
%{_mandir}/man5/epn.conf.5* %{_mandir}/man5/epn.conf.5*
%attr(644,root,root) %{_unitdir}/ipa-epn.service %attr(644,root,root) %{_unitdir}/ipa-epn.service
%attr(644,root,root) %{_unitdir}/ipa-epn.timer %attr(644,root,root) %{_unitdir}/ipa-epn.timer
%attr(644,root,root) %{_sysconfdir}/ipa/epn/expire_msg.template
%files -n python3-ipaclient %files -n python3-ipaclient
%doc README.md Contributors.txt %doc README.md Contributors.txt

View File

@ -106,3 +106,8 @@ dist_app_DATA = \
kdcproxyconfdir = $(IPA_SYSCONF_DIR)/kdcproxy kdcproxyconfdir = $(IPA_SYSCONF_DIR)/kdcproxy
dist_kdcproxyconf_DATA = \ dist_kdcproxyconf_DATA = \
kdcproxy.conf kdcproxy.conf
epnconfdir = $(IPA_SYSCONF_DIR)/epn
dist_epnconf_DATA = \
expire_msg.template \
$(NULL)

View File

@ -0,0 +1,5 @@
Hi {{ fullname }},
Your password will expire on {{ expiration }}.
Please change it as soon as possible.

View File

@ -36,6 +36,7 @@ from email.utils import formataddr, formatdate
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.header import Header from email.header import Header
from email.utils import make_msgid
from ipaclient.install.client import is_ipa_client_installed from ipaclient.install.client import is_ipa_client_installed
from ipaplatform.paths import paths from ipaplatform.paths import paths
@ -44,6 +45,8 @@ from ipalib.install import sysrestore
from ipapython import admintool, ipaldap from ipapython import admintool, ipaldap
from ipapython.dn import DN from ipapython.dn import DN
from jinja2 import Environment, FileSystemLoader, TemplateSyntaxError
EPN_CONF = "/etc/ipa/epn.conf" EPN_CONF = "/etc/ipa/epn.conf"
EPN_CONFIG = { EPN_CONFIG = {
@ -54,7 +57,11 @@ EPN_CONFIG = {
"smtp_timeout": 60, "smtp_timeout": 60,
"smtp_security": "none", "smtp_security": "none",
"smtp_admin": "root@localhost", "smtp_admin": "root@localhost",
"mail_from": None,
"notify_ttls": "28,14,7,3,1", "notify_ttls": "28,14,7,3,1",
"msg_charset": "utf8",
"msg_subtype": "plain",
"msg_subject": "Your password will expire soon.",
} }
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -116,7 +123,7 @@ class EPNUserList:
def add(self, entry): def add(self, entry):
"""Parses and appends an LDAP user entry with the uid, cn, """Parses and appends an LDAP user entry with the uid, cn,
krbpasswordexpiration and mail attributes. givenname, sn, krbpasswordexpiration and mail attributes.
""" """
try: try:
self._sorted = False self._sorted = False
@ -124,6 +131,8 @@ class EPNUserList:
dict( dict(
uid=str(entry["uid"].pop(0)), uid=str(entry["uid"].pop(0)),
cn=str(entry["cn"].pop(0)), cn=str(entry["cn"].pop(0)),
givenname=str(entry.get("givenname", "")),
sn=str(entry["sn"].pop(0)),
krbpasswordexpiration=str( krbpasswordexpiration=str(
entry["krbpasswordexpiration"].pop(0) entry["krbpasswordexpiration"].pop(0)
), ),
@ -190,6 +199,8 @@ class EPN(admintool.AdminTool):
self._ldap_data = [] self._ldap_data = []
self._date_ranges = [] self._date_ranges = []
self._mailer = None self._mailer = None
self.env = None
self.default_email_domain = None
@classmethod @classmethod
def add_options(cls, parser): def add_options(cls, parser):
@ -241,6 +252,7 @@ class EPN(admintool.AdminTool):
self._validate_configuration() self._validate_configuration()
self._parse_configuration() self._parse_configuration()
self._get_connection() self._get_connection()
self._read_ipa_configuration()
drop_privileges() drop_privileges()
if self.options.to_nbdays: if self.options.to_nbdays:
self._build_cli_date_ranges() self._build_cli_date_ranges()
@ -258,6 +270,8 @@ class EPN(admintool.AdminTool):
smtp_username=api.env.smtp_user, smtp_username=api.env.smtp_user,
smtp_password=api.env.smtp_password, smtp_password=api.env.smtp_password,
x_mailer=self.command_name, x_mailer=self.command_name,
msg_subtype=api.env.msg_subtype,
msg_charset=api.env.msg_charset,
) )
self._send_emails() self._send_emails()
@ -352,6 +366,17 @@ class EPN(admintool.AdminTool):
) )
) )
loader = FileSystemLoader(os.path.join(api.env.confdir, 'epn'))
self.env = Environment(loader=loader)
def _read_ipa_configuration(self):
"""Get the IPA configuration"""
api.Backend.rpcclient.connect()
result = api.Command.config_show()['result']
self.default_email_domain = result.get('ipadefaultemaildomain',
[None])[0]
api.Backend.rpcclient.disconnect()
def _get_connection(self): def _get_connection(self):
"""Create a connection to LDAP and bind to it. """Create a connection to LDAP and bind to it.
""" """
@ -389,7 +414,8 @@ class EPN(admintool.AdminTool):
) )
search_base = DN(api.env.container_user, api.env.basedn) search_base = DN(api.env.container_user, api.env.basedn)
attrs_list = ["uid", "krbpasswordexpiration", "mail", "cn"] attrs_list = ["uid", "krbpasswordexpiration", "mail", "cn",
"gn", "surname"]
search_filter = ( search_filter = (
"(&(!(nsaccountlock=TRUE)) \ "(&(!(nsaccountlock=TRUE)) \
@ -439,18 +465,29 @@ class EPN(admintool.AdminTool):
logger.error("IPA-EPN: mailer was not configured.") logger.error("IPA-EPN: mailer was not configured.")
return return
else: else:
try:
template = self.env.get_template("expire_msg.template")
except TemplateSyntaxError as e:
raise RuntimeError("Parsing template %s failed: %s" %
(e.filename, e))
if api.env.mail_from:
mail_from = api.env.mail_from
else:
mail_from = "noreply@%s" % self.default_email_domain
while self._expiring_password_user_list: while self._expiring_password_user_list:
entry = self._expiring_password_user_list.pop() entry = self._expiring_password_user_list.pop()
body = template.render(
uid=entry["uid"],
first=entry["givenname"],
last=entry["sn"],
fullname=entry["cn"],
expiration=entry["krbpasswordexpiration"],
)
self._mailer.send_message( self._mailer.send_message(
mail_subject="Your password is expiring.", mail_subject=api.env.msg_subject,
mail_body=os.linesep.join( mail_body=body,
[
"Hi %s, Your password will expire on %s."
% (entry["cn"], entry["krbpasswordexpiration"]),
"Please change it as soon as possible.",
]
),
subscribers=ast.literal_eval(entry["mail"]), subscribers=ast.literal_eval(entry["mail"]),
mail_from=mail_from,
) )
now = datetime.utcnow() now = datetime.utcnow()
expdate = datetime.strptime( expdate = datetime.strptime(
@ -524,6 +561,7 @@ class MTAClient:
self._disconnect() self._disconnect()
def send_message(self, message_str=None, subscribers=None): def send_message(self, message_str=None, subscribers=None):
result = None
try: try:
result = self._conn.sendmail( result = self._conn.sendmail(
api.env.smtp_admin, subscribers, message_str, api.env.smtp_admin, subscribers, message_str,
@ -640,18 +678,17 @@ class MailUserAgent:
smtp_username=None, smtp_username=None,
smtp_password=None, smtp_password=None,
x_mailer=None, x_mailer=None,
msg_subtype="plain",
msg_charset="utf8",
): ):
self._x_mailer = x_mailer self._x_mailer = x_mailer
self._subject = None self._subject = None
self._body = None self._body = None
self._subscribers = None self._subscribers = None
self._subtype = None
# Let it be plain or html? self._subtype = msg_subtype
self._subtype = "plain" self._charset = msg_charset
# UTF-8 only for now
self._charset = "utf-8"
self._msg = None self._msg = None
self._message_str = None self._message_str = None
@ -669,18 +706,20 @@ class MailUserAgent:
self._mta_client.cleanup() self._mta_client.cleanup()
def send_message( def send_message(
self, mail_subject=None, mail_body=None, subscribers=None self, mail_subject=None, mail_body=None, subscribers=None,
mail_from=None
): ):
"""Given mail_subject, mail_body, and subscribers, composes """Given mail_subject, mail_body, and subscribers, composes
the message and sends it. the message and sends it.
""" """
if None in [mail_subject, mail_body, subscribers]: if None in [mail_subject, mail_body, subscribers, mail_from]:
logger.error("IPA-EPN: Tried to send an empty message.") logger.error("IPA-EPN: Tried to send an empty message.")
return False return False
self._compose_message( self._compose_message(
mail_subject=mail_subject, mail_subject=mail_subject,
mail_body=mail_body, mail_body=mail_body,
subscribers=subscribers, subscribers=subscribers,
mail_from=mail_from,
) )
self._mta_client.send_message( self._mta_client.send_message(
message_str=self._message_str, subscribers=subscribers message_str=self._message_str, subscribers=subscribers
@ -688,7 +727,7 @@ class MailUserAgent:
return True return True
def _compose_message( def _compose_message(
self, mail_subject=None, mail_body=None, subscribers=None self, mail_subject, mail_body, subscribers, mail_from
): ):
"""The composer creates a MIME multipart message. """The composer creates a MIME multipart message.
""" """
@ -698,12 +737,11 @@ class MailUserAgent:
self._subscribers = subscribers self._subscribers = subscribers
self._msg = MIMEMultipart(_charset=self._charset) self._msg = MIMEMultipart(_charset=self._charset)
self._msg["From"] = formataddr( self._msg["From"] = formataddr(("IPA-EPN", mail_from))
("IPA-EPN", "noreply@%s" % socket.getfqdn())
)
self._msg["To"] = ", ".join(self._subscribers) self._msg["To"] = ", ".join(self._subscribers)
self._msg["Date"] = formatdate(localtime=True) self._msg["Date"] = formatdate(localtime=True)
self._msg["Subject"] = Header(self._subject, self._charset) self._msg["Subject"] = Header(self._subject, self._charset)
self._msg["Message-Id"] = make_msgid()
self._msg.preamble = "Multipart message" self._msg.preamble = "Multipart message"
if "X-Mailer" not in self._msg and self._x_mailer: if "X-Mailer" not in self._msg and self._x_mailer:
self._msg.add_header("X-Mailer", self._x_mailer) self._msg.add_header("X-Mailer", self._x_mailer)