We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Mailer doesn't quote names in To/From headers
## CKAN version
2.9.8, though it doesn't appear to have changed on 2.10
## Describe the bug
The username and site title are included in the To/From headers respectively, but they aren't quoted. When one contains, for example, commas, it's displayed as several addresses.
(When site_title is `Dept of A, B, and C`, the emails appear to be from `Dept@{host}, of@{host}, A@{host}, B@{host}, and C,`)
### Steps to reproduce
Steps to reproduce the behavior:
Set a site_title to a string containing commas and trigger an email somehow. I've been using `ckanext-harvester` notifications.
### Expected behavior
That the name would be quoted and therefore show as `Dept of A, B, and C`.

### Additional details
#4343 appeared to address the same / a similar issue



--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `ckan/lib/mailer.py`
Content:
```
1 # encoding: utf-8
2 from __future__ import annotations
3 
4 import codecs
5 import os
6 import smtplib
7 import socket
8 import logging
9 import mimetypes
10 from time import time
11 from typing import Any, Iterable, Optional, Tuple, Union, IO, cast
12 
13 from email.message import EmailMessage
14 from email import utils
15 
16 from ckan.common import _, config
17 
18 
19 import ckan
20 import ckan.model as model
21 import ckan.lib.helpers as h
22 from ckan.lib.base import render
23 
24 log = logging.getLogger(__name__)
25 AttachmentWithType = Union[
26     Tuple[str, IO[str], str],
27     Tuple[str, IO[bytes], str]
28 ]
29 AttachmentWithoutType = Union[Tuple[str, IO[str]], Tuple[str, IO[bytes]]]
30 Attachment = Union[AttachmentWithType, AttachmentWithoutType]
31 
32 
33 class MailerException(Exception):
34     pass
35 
36 
37 def _mail_recipient(
38         recipient_name: str, recipient_email: str, sender_name: str,
39         sender_url: str, subject: Any, body: Any,
40         body_html: Optional[Any] = None,
41         headers: Optional[dict[str, Any]] = None,
42         attachments: Optional[Iterable[Attachment]] = None) -> None:
43 
44     if not headers:
45         headers = {}
46 
47     if not attachments:
48         attachments = []
49 
50     mail_from = config.get('smtp.mail_from')
51 
52     reply_to = config.get('smtp.reply_to')
53 
54     msg = EmailMessage()
55 
56     msg.set_content(body, cte='base64')
57 
58     if body_html:
59         msg.add_alternative(body_html, subtype='html', cte='base64')
60 
61     for k, v in headers.items():
62         if k in msg.keys():
63             msg.replace_header(k, v)
64         else:
65             msg.add_header(k, v)
66     msg['Subject'] = subject
67     msg['From'] = _("%s <%s>") % (sender_name, mail_from)
68     msg['To'] = u"%s <%s>" % (recipient_name, recipient_email)
69     msg['Date'] = utils.formatdate(time())
70     if not config.get('ckan.hide_version'):
71         msg['X-Mailer'] = "CKAN %s" % ckan.__version__
72     # Check if extension is setting reply-to via headers or use config option
73     if reply_to and reply_to != '' and not msg['Reply-to']:
74         msg['Reply-to'] = reply_to
75 
76     for attachment in attachments:
77         if len(attachment) == 3:
78             name, _file, media_type = cast(AttachmentWithType, attachment)
79         else:
80             name, _file = cast(AttachmentWithoutType, attachment)
81             media_type = None
82 
83         if not media_type:
84             media_type, _encoding = mimetypes.guess_type(name)
85         if media_type:
86             main_type, sub_type = media_type.split('/')
87         else:
88             main_type = sub_type = None
89 
90         msg.add_attachment(
91             _file.read(), filename=name, maintype=main_type, subtype=sub_type)
92 
93     # Send the email using Python's smtplib.
94     smtp_server = config.get('smtp.server')
95     smtp_starttls = config.get('smtp.starttls')
96     smtp_user = config.get('smtp.user')
97     smtp_password = config.get('smtp.password')
98 
99     try:
100         smtp_connection = smtplib.SMTP(smtp_server)
101     except (socket.error, smtplib.SMTPConnectError) as e:
102         log.exception(e)
103         raise MailerException('SMTP server could not be connected to: "%s" %s'
104                               % (smtp_server, e))
105 
106     try:
107         # Identify ourselves and prompt the server for supported features.
108         smtp_connection.ehlo()
109 
110         # If 'smtp.starttls' is on in CKAN config, try to put the SMTP
111         # connection into TLS mode.
112         if smtp_starttls:
113             if smtp_connection.has_extn('STARTTLS'):
114                 smtp_connection.starttls()
115                 # Re-identify ourselves over TLS connection.
116                 smtp_connection.ehlo()
117             else:
118                 raise MailerException("SMTP server does not support STARTTLS")
119 
120         # If 'smtp.user' is in CKAN config, try to login to SMTP server.
121         if smtp_user:
122             assert smtp_password, ("If smtp.user is configured then "
123                                    "smtp.password must be configured as well.")
124             smtp_connection.login(smtp_user, smtp_password)
125 
126         smtp_connection.sendmail(mail_from, [recipient_email], msg.as_string())
127         log.info("Sent email to {0}".format(recipient_email))
128 
129     except smtplib.SMTPException as e:
130         msg = '%r' % e
131         log.exception(msg)
132         raise MailerException(msg)
133     finally:
134         smtp_connection.quit()
135 
136 
137 def mail_recipient(recipient_name: str,
138                    recipient_email: str,
139                    subject: str,
140                    body: str,
141                    body_html: Optional[str] = None,
142                    headers: Optional[dict[str, Any]] = None,
143                    attachments: Optional[Iterable[Attachment]] = None) -> None:
144 
145     '''Sends an email to a an email address.
146 
147     .. note:: You need to set up the :ref:`email-settings` to able to send
148         emails.
149 
150     :param recipient_name: the name of the recipient
151     :type recipient: string
152     :param recipient_email: the email address of the recipient
153     :type recipient: string
154 
155     :param subject: the email subject
156     :type subject: string
157     :param body: the email body, in plain text
158     :type body: string
159     :param body_html: the email body, in html format (optional)
160     :type body_html: string
161     :headers: extra headers to add to email, in the form
162         {'Header name': 'Header value'}
163     :type: dict
164     :attachments: a list of tuples containing file attachments to add to the
165         email. Tuples should contain the file name and a file-like object
166         pointing to the file contents::
167 
168             [
169                 ('some_report.csv', file_object),
170             ]
171 
172         Optionally, you can add a third element to the tuple containing the
173         media type. If not provided, it will be guessed using
174         the ``mimetypes`` module::
175 
176             [
177                 ('some_report.csv', file_object, 'text/csv'),
178             ]
179     :type: list
180     '''
181     site_title = config.get('ckan.site_title')
182     site_url = config.get('ckan.site_url')
183     return _mail_recipient(
184         recipient_name, recipient_email,
185         site_title, site_url, subject, body,
186         body_html=body_html, headers=headers, attachments=attachments)
187 
188 
189 def mail_user(recipient: model.User,
190               subject: str,
191               body: str,
192               body_html: Optional[str] = None,
193               headers: Optional[dict[str, Any]] = None,
194               attachments: Optional[Iterable[Attachment]] = None) -> None:
195     '''Sends an email to a CKAN user.
196 
197     You need to set up the :ref:`email-settings` to able to send emails.
198 
199     :param recipient: a CKAN user object
200     :type recipient: a model.User object
201 
202     For further parameters see
203     :py:func:`~ckan.lib.mailer.mail_recipient`.
204     '''
205 
206     if (recipient.email is None) or not len(recipient.email):
207         raise MailerException(_("No recipient email address available!"))
208     mail_recipient(
209         recipient.display_name, recipient.email, subject,
210         body, body_html=body_html, headers=headers, attachments=attachments)
211 
212 
213 def get_reset_link_body(user: model.User) -> str:
214     extra_vars = {
215         'reset_link': get_reset_link(user),
216         'site_title': config.get('ckan.site_title'),
217         'site_url': config.get('ckan.site_url'),
218         'user_name': user.name,
219     }
220     # NOTE: This template is translated
221     return render('emails/reset_password.txt', extra_vars)
222 
223 
224 def get_invite_body(user: model.User,
225                     group_dict: Optional[dict[str, Any]] = None,
226                     role: Optional[str] = None) -> str:
227     extra_vars = {
228         'reset_link': get_reset_link(user),
229         'site_title': config.get('ckan.site_title'),
230         'site_url': config.get('ckan.site_url'),
231         'user_name': user.name,
232     }
233 
234     if role:
235         extra_vars['role_name'] = h.roles_translated().get(role, _(role))
236     if group_dict:
237         group_type = (_('organization') if group_dict['is_organization']
238                       else _('group'))
239         extra_vars['group_type'] = group_type
240         extra_vars['group_title'] = group_dict.get('title')
241 
242     # NOTE: This template is translated
243     return render('emails/invite_user.txt', extra_vars)
244 
245 
246 def get_reset_link(user: model.User) -> str:
247     return h.url_for('user.perform_reset',
248                      id=user.id,
249                      key=user.reset_key,
250                      qualified=True)
251 
252 
253 def send_reset_link(user: model.User) -> None:
254     create_reset_key(user)
255     body = get_reset_link_body(user)
256     extra_vars = {
257         'site_title': config.get('ckan.site_title')
258     }
259     subject = render('emails/reset_password_subject.txt', extra_vars)
260 
261     # Make sure we only use the first line
262     subject = subject.split('\n')[0]
263 
264     mail_user(user, subject, body)
265 
266 
267 def send_invite(
268         user: model.User,
269         group_dict: Optional[dict[str, Any]] = None,
270         role: Optional[str] = None) -> None:
271     create_reset_key(user)
272     body = get_invite_body(user, group_dict, role)
273     extra_vars = {
274         'site_title': config.get('ckan.site_title')
275     }
276     subject = render('emails/invite_user_subject.txt', extra_vars)
277 
278     # Make sure we only use the first line
279     subject = subject.split('\n')[0]
280 
281     mail_user(user, subject, body)
282 
283 
284 def create_reset_key(user: model.User):
285     user.reset_key = make_key()
286     model.repo.commit_and_remove()
287 
288 
289 def make_key():
290     return codecs.encode(os.urandom(16), 'hex').decode()
291 
292 
293 def verify_reset_link(user: model.User, key: Optional[str]) -> bool:
294     if not key:
295         return False
296     if not user.reset_key or len(user.reset_key) < 5:
297         return False
298     return key.strip() == user.reset_key
299
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/ckan/lib/mailer.py b/ckan/lib/mailer.py
--- a/ckan/lib/mailer.py
+++ b/ckan/lib/mailer.py
@@ -64,8 +64,8 @@
         else:
             msg.add_header(k, v)
     msg['Subject'] = subject
-    msg['From'] = _("%s <%s>") % (sender_name, mail_from)
-    msg['To'] = u"%s <%s>" % (recipient_name, recipient_email)
+    msg['From'] = utils.formataddr((sender_name, mail_from))
+    msg['To'] = utils.formataddr((recipient_name, recipient_email))
     msg['Date'] = utils.formatdate(time())
     if not config.get('ckan.hide_version'):
         msg['X-Mailer'] = "CKAN %s" % ckan.__version__

{"golden_diff": "diff --git a/ckan/lib/mailer.py b/ckan/lib/mailer.py\n--- a/ckan/lib/mailer.py\n+++ b/ckan/lib/mailer.py\n@@ -64,8 +64,8 @@\n         else:\n             msg.add_header(k, v)\n     msg['Subject'] = subject\n-    msg['From'] = _(\"%s <%s>\") % (sender_name, mail_from)\n-    msg['To'] = u\"%s <%s>\" % (recipient_name, recipient_email)\n+    msg['From'] = utils.formataddr((sender_name, mail_from))\n+    msg['To'] = utils.formataddr((recipient_name, recipient_email))\n     msg['Date'] = utils.formatdate(time())\n     if not config.get('ckan.hide_version'):\n         msg['X-Mailer'] = \"CKAN %s\" % ckan.__version__\n", "issue": "Mailer doesn't quote names in To/From headers\n## CKAN version\r\n2.9.8, though it doesn't appear to have changed on 2.10\r\n## Describe the bug\r\nThe username and site title are included in the To/From headers respectively, but they aren't quoted. When one contains, for example, commas, it's displayed as several addresses.\r\n(When site_title is `Dept of A, B, and C`, the emails appear to be from `Dept@{host}, of@{host}, A@{host}, B@{host}, and C,`)\r\n### Steps to reproduce\r\nSteps to reproduce the behavior:\r\nSet a site_title to a string containing commas and trigger an email somehow. I've been using `ckanext-harvester` notifications.\r\n### Expected behavior\r\nThat the name would be quoted and therefore show as `Dept of A, B, and C`.\r\n\r\n### Additional details\r\n#4343 appeared to address the same / a similar issue\r\n\r\n\n", "before_files": [{"content": "# encoding: utf-8\nfrom __future__ import annotations\n\nimport codecs\nimport os\nimport smtplib\nimport socket\nimport logging\nimport mimetypes\nfrom time import time\nfrom typing import Any, Iterable, Optional, Tuple, Union, IO, cast\n\nfrom email.message import EmailMessage\nfrom email import utils\n\nfrom ckan.common import _, config\n\n\nimport ckan\nimport ckan.model as model\nimport ckan.lib.helpers as h\nfrom ckan.lib.base import render\n\nlog = logging.getLogger(__name__)\nAttachmentWithType = Union[\n    Tuple[str, IO[str], str],\n    Tuple[str, IO[bytes], str]\n]\nAttachmentWithoutType = Union[Tuple[str, IO[str]], Tuple[str, IO[bytes]]]\nAttachment = Union[AttachmentWithType, AttachmentWithoutType]\n\n\nclass MailerException(Exception):\n    pass\n\n\ndef _mail_recipient(\n        recipient_name: str, recipient_email: str, sender_name: str,\n        sender_url: str, subject: Any, body: Any,\n        body_html: Optional[Any] = None,\n        headers: Optional[dict[str, Any]] = None,\n        attachments: Optional[Iterable[Attachment]] = None) -> None:\n\n    if not headers:\n        headers = {}\n\n    if not attachments:\n        attachments = []\n\n    mail_from = config.get('smtp.mail_from')\n\n    reply_to = config.get('smtp.reply_to')\n\n    msg = EmailMessage()\n\n    msg.set_content(body, cte='base64')\n\n    if body_html:\n        msg.add_alternative(body_html, subtype='html', cte='base64')\n\n    for k, v in headers.items():\n        if k in msg.keys():\n            msg.replace_header(k, v)\n        else:\n            msg.add_header(k, v)\n    msg['Subject'] = subject\n    msg['From'] = _(\"%s <%s>\") % (sender_name, mail_from)\n    msg['To'] = u\"%s <%s>\" % (recipient_name, recipient_email)\n    msg['Date'] = utils.formatdate(time())\n    if not config.get('ckan.hide_version'):\n        msg['X-Mailer'] = \"CKAN %s\" % ckan.__version__\n    # Check if extension is setting reply-to via headers or use config option\n    if reply_to and reply_to != '' and not msg['Reply-to']:\n        msg['Reply-to'] = reply_to\n\n    for attachment in attachments:\n        if len(attachment) == 3:\n            name, _file, media_type = cast(AttachmentWithType, attachment)\n        else:\n            name, _file = cast(AttachmentWithoutType, attachment)\n            media_type = None\n\n        if not media_type:\n            media_type, _encoding = mimetypes.guess_type(name)\n        if media_type:\n            main_type, sub_type = media_type.split('/')\n        else:\n            main_type = sub_type = None\n\n        msg.add_attachment(\n            _file.read(), filename=name, maintype=main_type, subtype=sub_type)\n\n    # Send the email using Python's smtplib.\n    smtp_server = config.get('smtp.server')\n    smtp_starttls = config.get('smtp.starttls')\n    smtp_user = config.get('smtp.user')\n    smtp_password = config.get('smtp.password')\n\n    try:\n        smtp_connection = smtplib.SMTP(smtp_server)\n    except (socket.error, smtplib.SMTPConnectError) as e:\n        log.exception(e)\n        raise MailerException('SMTP server could not be connected to: \"%s\" %s'\n                              % (smtp_server, e))\n\n    try:\n        # Identify ourselves and prompt the server for supported features.\n        smtp_connection.ehlo()\n\n        # If 'smtp.starttls' is on in CKAN config, try to put the SMTP\n        # connection into TLS mode.\n        if smtp_starttls:\n            if smtp_connection.has_extn('STARTTLS'):\n                smtp_connection.starttls()\n                # Re-identify ourselves over TLS connection.\n                smtp_connection.ehlo()\n            else:\n                raise MailerException(\"SMTP server does not support STARTTLS\")\n\n        # If 'smtp.user' is in CKAN config, try to login to SMTP server.\n        if smtp_user:\n            assert smtp_password, (\"If smtp.user is configured then \"\n                                   \"smtp.password must be configured as well.\")\n            smtp_connection.login(smtp_user, smtp_password)\n\n        smtp_connection.sendmail(mail_from, [recipient_email], msg.as_string())\n        log.info(\"Sent email to {0}\".format(recipient_email))\n\n    except smtplib.SMTPException as e:\n        msg = '%r' % e\n        log.exception(msg)\n        raise MailerException(msg)\n    finally:\n        smtp_connection.quit()\n\n\ndef mail_recipient(recipient_name: str,\n                   recipient_email: str,\n                   subject: str,\n                   body: str,\n                   body_html: Optional[str] = None,\n                   headers: Optional[dict[str, Any]] = None,\n                   attachments: Optional[Iterable[Attachment]] = None) -> None:\n\n    '''Sends an email to a an email address.\n\n    .. note:: You need to set up the :ref:`email-settings` to able to send\n        emails.\n\n    :param recipient_name: the name of the recipient\n    :type recipient: string\n    :param recipient_email: the email address of the recipient\n    :type recipient: string\n\n    :param subject: the email subject\n    :type subject: string\n    :param body: the email body, in plain text\n    :type body: string\n    :param body_html: the email body, in html format (optional)\n    :type body_html: string\n    :headers: extra headers to add to email, in the form\n        {'Header name': 'Header value'}\n    :type: dict\n    :attachments: a list of tuples containing file attachments to add to the\n        email. Tuples should contain the file name and a file-like object\n        pointing to the file contents::\n\n            [\n                ('some_report.csv', file_object),\n            ]\n\n        Optionally, you can add a third element to the tuple containing the\n        media type. If not provided, it will be guessed using\n        the ``mimetypes`` module::\n\n            [\n                ('some_report.csv', file_object, 'text/csv'),\n            ]\n    :type: list\n    '''\n    site_title = config.get('ckan.site_title')\n    site_url = config.get('ckan.site_url')\n    return _mail_recipient(\n        recipient_name, recipient_email,\n        site_title, site_url, subject, body,\n        body_html=body_html, headers=headers, attachments=attachments)\n\n\ndef mail_user(recipient: model.User,\n              subject: str,\n              body: str,\n              body_html: Optional[str] = None,\n              headers: Optional[dict[str, Any]] = None,\n              attachments: Optional[Iterable[Attachment]] = None) -> None:\n    '''Sends an email to a CKAN user.\n\n    You need to set up the :ref:`email-settings` to able to send emails.\n\n    :param recipient: a CKAN user object\n    :type recipient: a model.User object\n\n    For further parameters see\n    :py:func:`~ckan.lib.mailer.mail_recipient`.\n    '''\n\n    if (recipient.email is None) or not len(recipient.email):\n        raise MailerException(_(\"No recipient email address available!\"))\n    mail_recipient(\n        recipient.display_name, recipient.email, subject,\n        body, body_html=body_html, headers=headers, attachments=attachments)\n\n\ndef get_reset_link_body(user: model.User) -> str:\n    extra_vars = {\n        'reset_link': get_reset_link(user),\n        'site_title': config.get('ckan.site_title'),\n        'site_url': config.get('ckan.site_url'),\n        'user_name': user.name,\n    }\n    # NOTE: This template is translated\n    return render('emails/reset_password.txt', extra_vars)\n\n\ndef get_invite_body(user: model.User,\n                    group_dict: Optional[dict[str, Any]] = None,\n                    role: Optional[str] = None) -> str:\n    extra_vars = {\n        'reset_link': get_reset_link(user),\n        'site_title': config.get('ckan.site_title'),\n        'site_url': config.get('ckan.site_url'),\n        'user_name': user.name,\n    }\n\n    if role:\n        extra_vars['role_name'] = h.roles_translated().get(role, _(role))\n    if group_dict:\n        group_type = (_('organization') if group_dict['is_organization']\n                      else _('group'))\n        extra_vars['group_type'] = group_type\n        extra_vars['group_title'] = group_dict.get('title')\n\n    # NOTE: This template is translated\n    return render('emails/invite_user.txt', extra_vars)\n\n\ndef get_reset_link(user: model.User) -> str:\n    return h.url_for('user.perform_reset',\n                     id=user.id,\n                     key=user.reset_key,\n                     qualified=True)\n\n\ndef send_reset_link(user: model.User) -> None:\n    create_reset_key(user)\n    body = get_reset_link_body(user)\n    extra_vars = {\n        'site_title': config.get('ckan.site_title')\n    }\n    subject = render('emails/reset_password_subject.txt', extra_vars)\n\n    # Make sure we only use the first line\n    subject = subject.split('\\n')[0]\n\n    mail_user(user, subject, body)\n\n\ndef send_invite(\n        user: model.User,\n        group_dict: Optional[dict[str, Any]] = None,\n        role: Optional[str] = None) -> None:\n    create_reset_key(user)\n    body = get_invite_body(user, group_dict, role)\n    extra_vars = {\n        'site_title': config.get('ckan.site_title')\n    }\n    subject = render('emails/invite_user_subject.txt', extra_vars)\n\n    # Make sure we only use the first line\n    subject = subject.split('\\n')[0]\n\n    mail_user(user, subject, body)\n\n\ndef create_reset_key(user: model.User):\n    user.reset_key = make_key()\n    model.repo.commit_and_remove()\n\n\ndef make_key():\n    return codecs.encode(os.urandom(16), 'hex').decode()\n\n\ndef verify_reset_link(user: model.User, key: Optional[str]) -> bool:\n    if not key:\n        return False\n    if not user.reset_key or len(user.reset_key) < 5:\n        return False\n    return key.strip() == user.reset_key\n", "path": "ckan/lib/mailer.py"}], "after_files": [{"content": "# encoding: utf-8\nfrom __future__ import annotations\n\nimport codecs\nimport os\nimport smtplib\nimport socket\nimport logging\nimport mimetypes\nfrom time import time\nfrom typing import Any, Iterable, Optional, Tuple, Union, IO, cast\n\nfrom email.message import EmailMessage\nfrom email import utils\n\nfrom ckan.common import _, config\n\n\nimport ckan\nimport ckan.model as model\nimport ckan.lib.helpers as h\nfrom ckan.lib.base import render\n\nlog = logging.getLogger(__name__)\nAttachmentWithType = Union[\n    Tuple[str, IO[str], str],\n    Tuple[str, IO[bytes], str]\n]\nAttachmentWithoutType = Union[Tuple[str, IO[str]], Tuple[str, IO[bytes]]]\nAttachment = Union[AttachmentWithType, AttachmentWithoutType]\n\n\nclass MailerException(Exception):\n    pass\n\n\ndef _mail_recipient(\n        recipient_name: str, recipient_email: str, sender_name: str,\n        sender_url: str, subject: Any, body: Any,\n        body_html: Optional[Any] = None,\n        headers: Optional[dict[str, Any]] = None,\n        attachments: Optional[Iterable[Attachment]] = None) -> None:\n\n    if not headers:\n        headers = {}\n\n    if not attachments:\n        attachments = []\n\n    mail_from = config.get('smtp.mail_from')\n\n    reply_to = config.get('smtp.reply_to')\n\n    msg = EmailMessage()\n\n    msg.set_content(body, cte='base64')\n\n    if body_html:\n        msg.add_alternative(body_html, subtype='html', cte='base64')\n\n    for k, v in headers.items():\n        if k in msg.keys():\n            msg.replace_header(k, v)\n        else:\n            msg.add_header(k, v)\n    msg['Subject'] = subject\n    msg['From'] = utils.formataddr((sender_name, mail_from))\n    msg['To'] = utils.formataddr((recipient_name, recipient_email))\n    msg['Date'] = utils.formatdate(time())\n    if not config.get('ckan.hide_version'):\n        msg['X-Mailer'] = \"CKAN %s\" % ckan.__version__\n    # Check if extension is setting reply-to via headers or use config option\n    if reply_to and reply_to != '' and not msg['Reply-to']:\n        msg['Reply-to'] = reply_to\n\n    for attachment in attachments:\n        if len(attachment) == 3:\n            name, _file, media_type = cast(AttachmentWithType, attachment)\n        else:\n            name, _file = cast(AttachmentWithoutType, attachment)\n            media_type = None\n\n        if not media_type:\n            media_type, _encoding = mimetypes.guess_type(name)\n        if media_type:\n            main_type, sub_type = media_type.split('/')\n        else:\n            main_type = sub_type = None\n\n        msg.add_attachment(\n            _file.read(), filename=name, maintype=main_type, subtype=sub_type)\n\n    # Send the email using Python's smtplib.\n    smtp_server = config.get('smtp.server')\n    smtp_starttls = config.get('smtp.starttls')\n    smtp_user = config.get('smtp.user')\n    smtp_password = config.get('smtp.password')\n\n    try:\n        smtp_connection = smtplib.SMTP(smtp_server)\n    except (socket.error, smtplib.SMTPConnectError) as e:\n        log.exception(e)\n        raise MailerException('SMTP server could not be connected to: \"%s\" %s'\n                              % (smtp_server, e))\n\n    try:\n        # Identify ourselves and prompt the server for supported features.\n        smtp_connection.ehlo()\n\n        # If 'smtp.starttls' is on in CKAN config, try to put the SMTP\n        # connection into TLS mode.\n        if smtp_starttls:\n            if smtp_connection.has_extn('STARTTLS'):\n                smtp_connection.starttls()\n                # Re-identify ourselves over TLS connection.\n                smtp_connection.ehlo()\n            else:\n                raise MailerException(\"SMTP server does not support STARTTLS\")\n\n        # If 'smtp.user' is in CKAN config, try to login to SMTP server.\n        if smtp_user:\n            assert smtp_password, (\"If smtp.user is configured then \"\n                                   \"smtp.password must be configured as well.\")\n            smtp_connection.login(smtp_user, smtp_password)\n\n        smtp_connection.sendmail(mail_from, [recipient_email], msg.as_string())\n        log.info(\"Sent email to {0}\".format(recipient_email))\n\n    except smtplib.SMTPException as e:\n        msg = '%r' % e\n        log.exception(msg)\n        raise MailerException(msg)\n    finally:\n        smtp_connection.quit()\n\n\ndef mail_recipient(recipient_name: str,\n                   recipient_email: str,\n                   subject: str,\n                   body: str,\n                   body_html: Optional[str] = None,\n                   headers: Optional[dict[str, Any]] = None,\n                   attachments: Optional[Iterable[Attachment]] = None) -> None:\n\n    '''Sends an email to a an email address.\n\n    .. note:: You need to set up the :ref:`email-settings` to able to send\n        emails.\n\n    :param recipient_name: the name of the recipient\n    :type recipient: string\n    :param recipient_email: the email address of the recipient\n    :type recipient: string\n\n    :param subject: the email subject\n    :type subject: string\n    :param body: the email body, in plain text\n    :type body: string\n    :param body_html: the email body, in html format (optional)\n    :type body_html: string\n    :headers: extra headers to add to email, in the form\n        {'Header name': 'Header value'}\n    :type: dict\n    :attachments: a list of tuples containing file attachments to add to the\n        email. Tuples should contain the file name and a file-like object\n        pointing to the file contents::\n\n            [\n                ('some_report.csv', file_object),\n            ]\n\n        Optionally, you can add a third element to the tuple containing the\n        media type. If not provided, it will be guessed using\n        the ``mimetypes`` module::\n\n            [\n                ('some_report.csv', file_object, 'text/csv'),\n            ]\n    :type: list\n    '''\n    site_title = config.get('ckan.site_title')\n    site_url = config.get('ckan.site_url')\n    return _mail_recipient(\n        recipient_name, recipient_email,\n        site_title, site_url, subject, body,\n        body_html=body_html, headers=headers, attachments=attachments)\n\n\ndef mail_user(recipient: model.User,\n              subject: str,\n              body: str,\n              body_html: Optional[str] = None,\n              headers: Optional[dict[str, Any]] = None,\n              attachments: Optional[Iterable[Attachment]] = None) -> None:\n    '''Sends an email to a CKAN user.\n\n    You need to set up the :ref:`email-settings` to able to send emails.\n\n    :param recipient: a CKAN user object\n    :type recipient: a model.User object\n\n    For further parameters see\n    :py:func:`~ckan.lib.mailer.mail_recipient`.\n    '''\n\n    if (recipient.email is None) or not len(recipient.email):\n        raise MailerException(_(\"No recipient email address available!\"))\n    mail_recipient(\n        recipient.display_name, recipient.email, subject,\n        body, body_html=body_html, headers=headers, attachments=attachments)\n\n\ndef get_reset_link_body(user: model.User) -> str:\n    extra_vars = {\n        'reset_link': get_reset_link(user),\n        'site_title': config.get('ckan.site_title'),\n        'site_url': config.get('ckan.site_url'),\n        'user_name': user.name,\n    }\n    # NOTE: This template is translated\n    return render('emails/reset_password.txt', extra_vars)\n\n\ndef get_invite_body(user: model.User,\n                    group_dict: Optional[dict[str, Any]] = None,\n                    role: Optional[str] = None) -> str:\n    extra_vars = {\n        'reset_link': get_reset_link(user),\n        'site_title': config.get('ckan.site_title'),\n        'site_url': config.get('ckan.site_url'),\n        'user_name': user.name,\n    }\n\n    if role:\n        extra_vars['role_name'] = h.roles_translated().get(role, _(role))\n    if group_dict:\n        group_type = (_('organization') if group_dict['is_organization']\n                      else _('group'))\n        extra_vars['group_type'] = group_type\n        extra_vars['group_title'] = group_dict.get('title')\n\n    # NOTE: This template is translated\n    return render('emails/invite_user.txt', extra_vars)\n\n\ndef get_reset_link(user: model.User) -> str:\n    return h.url_for('user.perform_reset',\n                     id=user.id,\n                     key=user.reset_key,\n                     qualified=True)\n\n\ndef send_reset_link(user: model.User) -> None:\n    create_reset_key(user)\n    body = get_reset_link_body(user)\n    extra_vars = {\n        'site_title': config.get('ckan.site_title')\n    }\n    subject = render('emails/reset_password_subject.txt', extra_vars)\n\n    # Make sure we only use the first line\n    subject = subject.split('\\n')[0]\n\n    mail_user(user, subject, body)\n\n\ndef send_invite(\n        user: model.User,\n        group_dict: Optional[dict[str, Any]] = None,\n        role: Optional[str] = None) -> None:\n    create_reset_key(user)\n    body = get_invite_body(user, group_dict, role)\n    extra_vars = {\n        'site_title': config.get('ckan.site_title')\n    }\n    subject = render('emails/invite_user_subject.txt', extra_vars)\n\n    # Make sure we only use the first line\n    subject = subject.split('\\n')[0]\n\n    mail_user(user, subject, body)\n\n\ndef create_reset_key(user: model.User):\n    user.reset_key = make_key()\n    model.repo.commit_and_remove()\n\n\ndef make_key():\n    return codecs.encode(os.urandom(16), 'hex').decode()\n\n\ndef verify_reset_link(user: model.User, key: Optional[str]) -> bool:\n    if not key:\n        return False\n    if not user.reset_key or len(user.reset_key) < 5:\n        return False\n    return key.strip() == user.reset_key\n", "path": "ckan/lib/mailer.py"}]}