|
| 1 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 2 | +# or more contributor license agreements. See the NOTICE file |
| 3 | +# distributed with this work for additional information |
| 4 | +# regarding copyright ownership. The ASF licenses this file |
| 5 | +# to you under the Apache License, Version 2.0 (the |
| 6 | +# "License"); you may not use this file except in compliance |
| 7 | +# with the License. You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, |
| 12 | +# software distributed under the License is distributed on an |
| 13 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 14 | +# KIND, either express or implied. See the License for the |
| 15 | +# specific language governing permissions and limitations |
| 16 | +# under the License. |
| 17 | + |
| 18 | + |
| 19 | +import logging |
| 20 | +import os |
| 21 | +import re |
| 22 | +import smtplib |
| 23 | +import ssl |
| 24 | +from email.mime.application import MIMEApplication |
| 25 | +from email.mime.image import MIMEImage |
| 26 | +from email.mime.multipart import MIMEMultipart |
| 27 | +from email.mime.text import MIMEText |
| 28 | +from email.utils import formatdate |
| 29 | +from typing import Any, Optional |
| 30 | + |
| 31 | +from flask import g |
| 32 | + |
| 33 | +from superset import db |
| 34 | +from superset.reports.models import ReportSchedule, ReportSourceFormat |
| 35 | +from superset.reports.types import HeaderDataType |
| 36 | + |
| 37 | +logger = logging.getLogger(__name__) |
| 38 | + |
| 39 | + |
| 40 | +def get_email_address_list(address_string: str) -> list[str]: |
| 41 | + address_string_list: list[str] = [] |
| 42 | + if isinstance(address_string, str): |
| 43 | + address_string_list = re.split(r",|\s|;", address_string) |
| 44 | + return [x.strip() for x in address_string_list if x.strip()] |
| 45 | + |
| 46 | + |
| 47 | +def _get_log_data() -> HeaderDataType: |
| 48 | + global_context = getattr(g, "context", {}) or {} |
| 49 | + chart_id = global_context.get("chart_id") |
| 50 | + dashboard_id = global_context.get("dashboard_id") |
| 51 | + report_schedule_id = global_context.get("report_schedule_id") |
| 52 | + report_source: str = "" |
| 53 | + report_format: str = "" |
| 54 | + report_type: str = "" |
| 55 | + owners: list[int] = [] |
| 56 | + |
| 57 | + # intentionally creating a new session to |
| 58 | + # keep the logging session separate from the |
| 59 | + # main session |
| 60 | + session = db.create_scoped_session() |
| 61 | + report_schedule = ( |
| 62 | + session.query(ReportSchedule).filter_by(id=report_schedule_id).one_or_none() |
| 63 | + ) |
| 64 | + session.close() |
| 65 | + |
| 66 | + if report_schedule is not None: |
| 67 | + report_type = report_schedule.type |
| 68 | + report_format = report_schedule.report_format |
| 69 | + owners = report_schedule.owners |
| 70 | + report_source = ( |
| 71 | + ReportSourceFormat.DASHBOARD |
| 72 | + if report_schedule.dashboard_id |
| 73 | + else ReportSourceFormat.CHART |
| 74 | + ) |
| 75 | + |
| 76 | + log_data: HeaderDataType = { |
| 77 | + "notification_type": report_type, |
| 78 | + "notification_source": report_source, |
| 79 | + "notification_format": report_format, |
| 80 | + "chart_id": chart_id, |
| 81 | + "dashboard_id": dashboard_id, |
| 82 | + "owners": owners, |
| 83 | + } |
| 84 | + return log_data |
| 85 | + |
| 86 | + |
| 87 | +def send_email_smtp( |
| 88 | + to: str, |
| 89 | + subject: str, |
| 90 | + html_content: str, |
| 91 | + config: dict[str, Any], |
| 92 | + files: Optional[list[str]] = None, |
| 93 | + data: Optional[dict[str, str]] = None, |
| 94 | + images: Optional[dict[str, bytes]] = None, |
| 95 | + dryrun: bool = False, |
| 96 | + cc: Optional[str] = None, |
| 97 | + bcc: Optional[str] = None, |
| 98 | + mime_subtype: str = "mixed", |
| 99 | +) -> None: |
| 100 | + """ |
| 101 | + Send an email with html content, eg: |
| 102 | + send_email_smtp( |
| 103 | + '[email protected]', 'foo', '<b>Foo</b> bar',['/dev/null'], dryrun=True) |
| 104 | + """ |
| 105 | + smtp_mail_from = config["SMTP_MAIL_FROM"] |
| 106 | + smtp_mail_to = get_email_address_list(to) |
| 107 | + |
| 108 | + msg = MIMEMultipart(mime_subtype) |
| 109 | + msg["Subject"] = subject |
| 110 | + msg["From"] = smtp_mail_from |
| 111 | + msg["To"] = ", ".join(smtp_mail_to) |
| 112 | + |
| 113 | + msg.preamble = "This is a multi-part message in MIME format." |
| 114 | + |
| 115 | + recipients = smtp_mail_to |
| 116 | + if cc: |
| 117 | + smtp_mail_cc = get_email_address_list(cc) |
| 118 | + msg["CC"] = ", ".join(smtp_mail_cc) |
| 119 | + recipients = recipients + smtp_mail_cc |
| 120 | + |
| 121 | + if bcc: |
| 122 | + # don't add bcc in header |
| 123 | + smtp_mail_bcc = get_email_address_list(bcc) |
| 124 | + recipients = recipients + smtp_mail_bcc |
| 125 | + |
| 126 | + msg["Date"] = formatdate(localtime=True) |
| 127 | + mime_text = MIMEText(html_content, "html") |
| 128 | + msg.attach(mime_text) |
| 129 | + |
| 130 | + # Attach files by reading them from disk |
| 131 | + for fname in files or []: |
| 132 | + basename = os.path.basename(fname) |
| 133 | + with open(fname, "rb") as f: |
| 134 | + msg.attach( |
| 135 | + MIMEApplication( |
| 136 | + f.read(), |
| 137 | + Content_Disposition=f"attachment; filename='{basename}'", |
| 138 | + Name=basename, |
| 139 | + ) |
| 140 | + ) |
| 141 | + |
| 142 | + # Attach any files passed directly |
| 143 | + for name, body in (data or {}).items(): |
| 144 | + msg.attach( |
| 145 | + MIMEApplication( |
| 146 | + body, Content_Disposition=f"attachment; filename='{name}'", Name=name |
| 147 | + ) |
| 148 | + ) |
| 149 | + |
| 150 | + # Attach any inline images, which may be required for display in |
| 151 | + # HTML content (inline) |
| 152 | + for msgid, imgdata in (images or {}).items(): |
| 153 | + formatted_time = formatdate(localtime=True) |
| 154 | + file_name = f"{subject} {formatted_time}" |
| 155 | + image = MIMEImage(imgdata, name=file_name) |
| 156 | + image.add_header("Content-ID", f"<{msgid}>") |
| 157 | + image.add_header("Content-Disposition", "inline") |
| 158 | + msg.attach(image) |
| 159 | + msg_mutator = config["EMAIL_HEADER_MUTATOR"] |
| 160 | + # the base notification returns the message without any editing. |
| 161 | + header_data = _get_log_data() |
| 162 | + new_msg = msg_mutator(msg, **(header_data or {})) |
| 163 | + send_mime_email(smtp_mail_from, recipients, new_msg, config, dryrun=dryrun) |
| 164 | + |
| 165 | + |
| 166 | +def send_mime_email( |
| 167 | + e_from: str, |
| 168 | + e_to: list[str], |
| 169 | + mime_msg: MIMEMultipart, |
| 170 | + config: dict[str, Any], |
| 171 | + dryrun: bool = False, |
| 172 | +) -> None: |
| 173 | + smtp_host = config["SMTP_HOST"] |
| 174 | + smtp_port = config["SMTP_PORT"] |
| 175 | + smtp_user = config["SMTP_USER"] |
| 176 | + smtp_password = config["SMTP_PASSWORD"] |
| 177 | + smtp_starttls = config["SMTP_STARTTLS"] |
| 178 | + smtp_ssl = config["SMTP_SSL"] |
| 179 | + smtp_ssl_server_auth = config["SMTP_SSL_SERVER_AUTH"] |
| 180 | + |
| 181 | + if dryrun: |
| 182 | + logger.info("Dryrun enabled, email notification content is below:") |
| 183 | + logger.info(mime_msg.as_string()) |
| 184 | + return |
| 185 | + |
| 186 | + # Default ssl context is SERVER_AUTH using the default system |
| 187 | + # root CA certificates |
| 188 | + ssl_context = ssl.create_default_context() if smtp_ssl_server_auth else None |
| 189 | + smtp = ( |
| 190 | + smtplib.SMTP_SSL(smtp_host, smtp_port, context=ssl_context) |
| 191 | + if smtp_ssl |
| 192 | + else smtplib.SMTP(smtp_host, smtp_port) |
| 193 | + ) |
| 194 | + if smtp_starttls: |
| 195 | + smtp.starttls(context=ssl_context) |
| 196 | + if smtp_user and smtp_password: |
| 197 | + smtp.login(smtp_user, smtp_password) |
| 198 | + logger.debug("Sent an email to %s", str(e_to)) |
| 199 | + smtp.sendmail(e_from, e_to, mime_msg.as_string()) |
| 200 | + smtp.quit() |
0 commit comments