1#
2# Licensed to the Apache Software Foundation (ASF) under one
3# or more contributor license agreements. See the NOTICE file
4# distributed with this work for additional information
5# regarding copyright ownership. The ASF licenses this file
6# to you under the Apache License, Version 2.0 (the
7# "License"); you may not use this file except in compliance
8# with the License. You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing,
13# software distributed under the License is distributed on an
14# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15# KIND, either express or implied. See the License for the
16# specific language governing permissions and limitations
17# under the License.
18from __future__ import annotations
19
20import collections.abc
21import logging
22import os
23import smtplib
24import ssl
25import warnings
26from email.mime.application import MIMEApplication
27from email.mime.multipart import MIMEMultipart
28from email.mime.text import MIMEText
29from email.utils import formatdate
30from typing import Any, Iterable
31
32import re2
33
34from airflow.configuration import conf
35from airflow.exceptions import AirflowConfigException, AirflowException, RemovedInAirflow3Warning
36
37log = logging.getLogger(__name__)
38
39
40def send_email(
41 to: list[str] | Iterable[str],
42 subject: str,
43 html_content: str,
44 files: list[str] | None = None,
45 dryrun: bool = False,
46 cc: str | Iterable[str] | None = None,
47 bcc: str | Iterable[str] | None = None,
48 mime_subtype: str = "mixed",
49 mime_charset: str = "utf-8",
50 conn_id: str | None = None,
51 custom_headers: dict[str, Any] | None = None,
52 **kwargs,
53) -> None:
54 """
55 Send an email using the backend specified in the *EMAIL_BACKEND* configuration option.
56
57 :param to: A list or iterable of email addresses to send the email to.
58 :param subject: The subject of the email.
59 :param html_content: The content of the email in HTML format.
60 :param files: A list of paths to files to attach to the email.
61 :param dryrun: If *True*, the email will not actually be sent. Default: *False*.
62 :param cc: A string or iterable of strings containing email addresses to send a copy of the email to.
63 :param bcc: A string or iterable of strings containing email addresses to send a
64 blind carbon copy of the email to.
65 :param mime_subtype: The subtype of the MIME message. Default: "mixed".
66 :param mime_charset: The charset of the email. Default: "utf-8".
67 :param conn_id: The connection ID to use for the backend. If not provided, the default connection
68 specified in the *EMAIL_CONN_ID* configuration option will be used.
69 :param custom_headers: A dictionary of additional headers to add to the MIME message.
70 No validations are run on these values, and they should be able to be encoded.
71 :param kwargs: Additional keyword arguments to pass to the backend.
72 """
73 backend = conf.getimport("email", "EMAIL_BACKEND")
74 backend_conn_id = conn_id or conf.get("email", "EMAIL_CONN_ID")
75 from_email = conf.get("email", "from_email", fallback=None)
76
77 to_list = get_email_address_list(to)
78 to_comma_separated = ", ".join(to_list)
79
80 return backend(
81 to_comma_separated,
82 subject,
83 html_content,
84 files=files,
85 dryrun=dryrun,
86 cc=cc,
87 bcc=bcc,
88 mime_subtype=mime_subtype,
89 mime_charset=mime_charset,
90 conn_id=backend_conn_id,
91 from_email=from_email,
92 custom_headers=custom_headers,
93 **kwargs,
94 )
95
96
97def send_email_smtp(
98 to: str | Iterable[str],
99 subject: str,
100 html_content: str,
101 files: list[str] | None = None,
102 dryrun: bool = False,
103 cc: str | Iterable[str] | None = None,
104 bcc: str | Iterable[str] | None = None,
105 mime_subtype: str = "mixed",
106 mime_charset: str = "utf-8",
107 conn_id: str = "smtp_default",
108 from_email: str | None = None,
109 custom_headers: dict[str, Any] | None = None,
110 **kwargs,
111) -> None:
112 """Send an email with html content.
113
114 :param to: Recipient email address or list of addresses.
115 :param subject: Email subject.
116 :param html_content: Email body in HTML format.
117 :param files: List of file paths to attach to the email.
118 :param dryrun: If True, the email will not be sent, but all other actions will be performed.
119 :param cc: Carbon copy recipient email address or list of addresses.
120 :param bcc: Blind carbon copy recipient email address or list of addresses.
121 :param mime_subtype: MIME subtype of the email.
122 :param mime_charset: MIME charset of the email.
123 :param conn_id: Connection ID of the SMTP server.
124 :param from_email: Sender email address.
125 :param custom_headers: Dictionary of custom headers to include in the email.
126 :param kwargs: Additional keyword arguments.
127
128 >>> send_email("test@example.com", "foo", "<b>Foo</b> bar", ["/dev/null"], dryrun=True)
129 """
130 smtp_mail_from = conf.get("smtp", "SMTP_MAIL_FROM")
131
132 if smtp_mail_from is not None:
133 mail_from = smtp_mail_from
134 else:
135 if from_email is None:
136 raise ValueError(
137 "You should set from email - either by smtp/smtp_mail_from config or `from_email` parameter"
138 )
139 mail_from = from_email
140
141 msg, recipients = build_mime_message(
142 mail_from=mail_from,
143 to=to,
144 subject=subject,
145 html_content=html_content,
146 files=files,
147 cc=cc,
148 bcc=bcc,
149 mime_subtype=mime_subtype,
150 mime_charset=mime_charset,
151 custom_headers=custom_headers,
152 )
153
154 send_mime_email(e_from=mail_from, e_to=recipients, mime_msg=msg, conn_id=conn_id, dryrun=dryrun)
155
156
157def build_mime_message(
158 mail_from: str | None,
159 to: str | Iterable[str],
160 subject: str,
161 html_content: str,
162 files: list[str] | None = None,
163 cc: str | Iterable[str] | None = None,
164 bcc: str | Iterable[str] | None = None,
165 mime_subtype: str = "mixed",
166 mime_charset: str = "utf-8",
167 custom_headers: dict[str, Any] | None = None,
168) -> tuple[MIMEMultipart, list[str]]:
169 """
170 Build a MIME message that can be used to send an email and returns a full list of recipients.
171
172 :param mail_from: Email address to set as the email's "From" field.
173 :param to: A string or iterable of strings containing email addresses to set as the email's "To" field.
174 :param subject: The subject of the email.
175 :param html_content: The content of the email in HTML format.
176 :param files: A list of paths to files to be attached to the email.
177 :param cc: A string or iterable of strings containing email addresses to set as the email's "CC" field.
178 :param bcc: A string or iterable of strings containing email addresses to set as the email's "BCC" field.
179 :param mime_subtype: The subtype of the MIME message. Default: "mixed".
180 :param mime_charset: The charset of the email. Default: "utf-8".
181 :param custom_headers: Additional headers to add to the MIME message. No validations are run on these
182 values, and they should be able to be encoded.
183 :return: A tuple containing the email as a MIMEMultipart object and a list of recipient email addresses.
184 """
185 to = get_email_address_list(to)
186
187 msg = MIMEMultipart(mime_subtype)
188 msg["Subject"] = subject
189 if mail_from:
190 msg["From"] = mail_from
191 msg["To"] = ", ".join(to)
192 recipients = to
193 if cc:
194 cc = get_email_address_list(cc)
195 msg["CC"] = ", ".join(cc)
196 recipients += cc
197
198 if bcc:
199 # don't add bcc in header
200 bcc = get_email_address_list(bcc)
201 recipients += bcc
202
203 msg["Date"] = formatdate(localtime=True)
204 mime_text = MIMEText(html_content, "html", mime_charset)
205 msg.attach(mime_text)
206
207 for fname in files or []:
208 basename = os.path.basename(fname)
209 with open(fname, "rb") as file:
210 part = MIMEApplication(file.read(), Name=basename)
211 part["Content-Disposition"] = f'attachment; filename="{basename}"'
212 part["Content-ID"] = f"<{basename}>"
213 msg.attach(part)
214
215 if custom_headers:
216 for header_key, header_value in custom_headers.items():
217 msg[header_key] = header_value
218
219 return msg, recipients
220
221
222def send_mime_email(
223 e_from: str,
224 e_to: str | list[str],
225 mime_msg: MIMEMultipart,
226 conn_id: str = "smtp_default",
227 dryrun: bool = False,
228) -> None:
229 """
230 Send a MIME email.
231
232 :param e_from: The email address of the sender.
233 :param e_to: The email address or a list of email addresses of the recipient(s).
234 :param mime_msg: The MIME message to send.
235 :param conn_id: The ID of the SMTP connection to use.
236 :param dryrun: If True, the email will not be sent, but a log message will be generated.
237 """
238 smtp_host = conf.get_mandatory_value("smtp", "SMTP_HOST")
239 smtp_port = conf.getint("smtp", "SMTP_PORT")
240 smtp_starttls = conf.getboolean("smtp", "SMTP_STARTTLS")
241 smtp_ssl = conf.getboolean("smtp", "SMTP_SSL")
242 smtp_retry_limit = conf.getint("smtp", "SMTP_RETRY_LIMIT")
243 smtp_timeout = conf.getint("smtp", "SMTP_TIMEOUT")
244 smtp_user = None
245 smtp_password = None
246
247 if conn_id is not None:
248 try:
249 from airflow.hooks.base import BaseHook
250
251 airflow_conn = BaseHook.get_connection(conn_id)
252 smtp_user = airflow_conn.login
253 smtp_password = airflow_conn.password
254 except AirflowException:
255 pass
256 if smtp_user is None or smtp_password is None:
257 warnings.warn(
258 "Fetching SMTP credentials from configuration variables will be deprecated in a future "
259 "release. Please set credentials using a connection instead.",
260 RemovedInAirflow3Warning,
261 stacklevel=2,
262 )
263 try:
264 smtp_user = conf.get("smtp", "SMTP_USER")
265 smtp_password = conf.get("smtp", "SMTP_PASSWORD")
266 except AirflowConfigException:
267 log.debug("No user/password found for SMTP, so logging in with no authentication.")
268
269 if not dryrun:
270 for attempt in range(1, smtp_retry_limit + 1):
271 log.info("Email alerting: attempt %s", str(attempt))
272 try:
273 smtp_conn = _get_smtp_connection(smtp_host, smtp_port, smtp_timeout, smtp_ssl)
274 except smtplib.SMTPServerDisconnected:
275 if attempt == smtp_retry_limit:
276 raise
277 else:
278 if smtp_starttls:
279 smtp_conn.starttls()
280 if smtp_user and smtp_password:
281 smtp_conn.login(smtp_user, smtp_password)
282 log.info("Sent an alert email to %s", e_to)
283 smtp_conn.sendmail(e_from, e_to, mime_msg.as_string())
284 smtp_conn.quit()
285 break
286
287
288def get_email_address_list(addresses: str | Iterable[str]) -> list[str]:
289 """
290 Return a list of email addresses from the provided input.
291
292 :param addresses: A string or iterable of strings containing email addresses.
293 :return: A list of email addresses.
294 :raises TypeError: If the input is not a string or iterable of strings.
295 """
296 if isinstance(addresses, str):
297 return _get_email_list_from_str(addresses)
298 elif isinstance(addresses, collections.abc.Iterable):
299 if not all(isinstance(item, str) for item in addresses):
300 raise TypeError("The items in your iterable must be strings.")
301 return list(addresses)
302 else:
303 raise TypeError(f"Unexpected argument type: Received '{type(addresses).__name__}'.")
304
305
306def _get_smtp_connection(host: str, port: int, timeout: int, with_ssl: bool) -> smtplib.SMTP:
307 """
308 Return an SMTP connection to the specified host and port, with optional SSL encryption.
309
310 :param host: The hostname or IP address of the SMTP server.
311 :param port: The port number to connect to on the SMTP server.
312 :param timeout: The timeout in seconds for the connection.
313 :param with_ssl: Whether to use SSL encryption for the connection.
314 :return: An SMTP connection to the specified host and port.
315 """
316 if not with_ssl:
317 return smtplib.SMTP(host=host, port=port, timeout=timeout)
318 else:
319 ssl_context_string = conf.get("email", "SSL_CONTEXT")
320 if ssl_context_string == "default":
321 ssl_context = ssl.create_default_context()
322 elif ssl_context_string == "none":
323 ssl_context = None
324 else:
325 raise RuntimeError(
326 f"The email.ssl_context configuration variable must "
327 f"be set to 'default' or 'none' and is '{ssl_context_string}."
328 )
329 return smtplib.SMTP_SSL(host=host, port=port, timeout=timeout, context=ssl_context)
330
331
332def _get_email_list_from_str(addresses: str) -> list[str]:
333 """
334 Extract a list of email addresses from a string.
335
336 The string can contain multiple email addresses separated
337 by any of the following delimiters: ',' or ';'.
338
339 :param addresses: A string containing one or more email addresses.
340 :return: A list of email addresses.
341 """
342 pattern = r"\s*[,;]\s*"
343 return re2.split(pattern, addresses)