mirror of
https://github.com/apache/superset.git
synced 2026-08-05 13:32:41 +00:00
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
"""
|
|
Email rendering and delivery for dashboard Excel exports.
|
|
|
|
Bodies use inline styles only (no external CSS, no logo) to match Superset's
|
|
existing report notification emails, and all user-controlled values (dashboard
|
|
title, chart names) are HTML-escaped to avoid injection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from flask import current_app
|
|
from flask_babel import gettext as __, ngettext
|
|
from markupsafe import escape
|
|
|
|
from superset.utils.core import send_email_smtp
|
|
|
|
_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
_FOOTER_STYLE = "color:#888;font-size:12px;"
|
|
_BUTTON_STYLE = (
|
|
"display:inline-block;padding:10px 16px;background:#20a7c9;color:#ffffff;"
|
|
"text-decoration:none;border-radius:4px;"
|
|
)
|
|
|
|
# Reason keys under which the export task groups charts it could not export.
|
|
# The task classifies each omitted chart under one of these; the email renders a
|
|
# separate, labelled section per non-empty group with its own remediation text.
|
|
ERROR_NO_QUERY_CONTEXT = "no-query-context"
|
|
ERROR_GENERAL = "general-exception"
|
|
|
|
|
|
def _fmt(dt: datetime) -> str:
|
|
return dt.strftime(_DATETIME_FORMAT)
|
|
|
|
|
|
def _humanize_ttl(seconds: int) -> str:
|
|
"""Render a TTL as a human-readable, pluralized, translatable duration.
|
|
|
|
Whole hours read as "24 hours"; sub-hour and non-hour values keep their
|
|
minutes (e.g. "1 hour 30 minutes", "15 minutes") so the stated lifetime
|
|
always matches the real pre-signed URL expiration.
|
|
"""
|
|
hours, remainder = divmod(seconds, 3600)
|
|
parts: list[str] = []
|
|
if hours:
|
|
parts.append(ngettext("%(num)d hour", "%(num)d hours", hours))
|
|
if minutes := remainder // 60:
|
|
parts.append(ngettext("%(num)d minute", "%(num)d minutes", minutes))
|
|
if not parts:
|
|
parts.append(ngettext("%(num)d second", "%(num)d seconds", seconds))
|
|
return " ".join(parts)
|
|
|
|
|
|
def build_subject(dashboard_title: str, *, success: bool) -> str:
|
|
"""Build the email subject, prefixed with EMAIL_REPORTS_SUBJECT_PREFIX."""
|
|
prefix = current_app.config["EMAIL_REPORTS_SUBJECT_PREFIX"]
|
|
if success:
|
|
return prefix + __(
|
|
"Your dashboard export is ready: %(title)s", title=dashboard_title
|
|
)
|
|
return prefix + __(
|
|
"Your dashboard export could not be completed: %(title)s",
|
|
title=dashboard_title,
|
|
)
|
|
|
|
|
|
def _errored_section(errored: dict[str, list[str]]) -> str:
|
|
"""Render one labelled, translated sub-list per non-empty error group.
|
|
|
|
``errored`` maps a reason key (see the ``ERROR_*`` constants) to the labels
|
|
of the charts that were omitted for that reason. Known reasons are rendered
|
|
first, in a stable order, each with its own remediation text; any unknown
|
|
reason key falls back to a generic message so nothing is silently dropped.
|
|
"""
|
|
if not errored:
|
|
return ""
|
|
notes = {
|
|
ERROR_NO_QUERY_CONTEXT: __(
|
|
"The following charts were omitted because they have no saved query "
|
|
"context. To include them, open each chart in Explore and re-save."
|
|
),
|
|
ERROR_GENERAL: __(
|
|
"The following charts were omitted because an error occurred while "
|
|
"exporting them:"
|
|
),
|
|
}
|
|
fallback = __("The following charts could not be exported:")
|
|
ordered = [ERROR_NO_QUERY_CONTEXT, ERROR_GENERAL]
|
|
reasons = ordered + [reason for reason in errored if reason not in ordered]
|
|
sections = []
|
|
for reason in reasons:
|
|
labels = errored.get(reason)
|
|
if not labels:
|
|
continue
|
|
note = notes.get(reason, fallback)
|
|
items = "".join(f"<li>{escape(label)}</li>" for label in labels)
|
|
sections.append(f"<p>{note}</p><ul>{items}</ul>")
|
|
return "".join(sections)
|
|
|
|
|
|
def build_success_email(
|
|
dashboard_title: str,
|
|
download_url: str,
|
|
requested_at: datetime,
|
|
expires_at: datetime,
|
|
ttl_seconds: int,
|
|
errored: dict[str, list[str]],
|
|
) -> str:
|
|
"""Render the success email body (HTML)."""
|
|
title = escape(dashboard_title)
|
|
url = escape(download_url)
|
|
ready = __('Your export of "%(title)s" is ready.', title=title)
|
|
button = __("Download Excel file")
|
|
expiry = __(
|
|
"This link expires in %(duration)s (%(when)s UTC).",
|
|
duration=_humanize_ttl(ttl_seconds),
|
|
when=_fmt(expires_at),
|
|
)
|
|
requested = __(
|
|
"This export was requested on %(when)s UTC.", when=_fmt(requested_at)
|
|
)
|
|
disclaimer = __("If you did not request this, you can ignore this email.")
|
|
return (
|
|
'<html><body style="font-family:Arial,sans-serif;color:#333;">'
|
|
f"<p>{ready}</p>"
|
|
f'<p><a href="{url}" style="{_BUTTON_STYLE}">{button}</a></p>'
|
|
f"<p>{expiry}</p>"
|
|
f"{_errored_section(errored)}"
|
|
"<hr/>"
|
|
f'<p style="{_FOOTER_STYLE}">{requested}<br/>{disclaimer}</p>'
|
|
"</body></html>"
|
|
)
|
|
|
|
|
|
def build_failure_email(dashboard_title: str, requested_at: datetime) -> str:
|
|
"""Render the failure email body (HTML)."""
|
|
title = escape(dashboard_title)
|
|
failed = __('Your export of "%(title)s" could not be completed.', title=title)
|
|
advice = __(
|
|
"An error occurred while generating the file. Please try again, or "
|
|
"contact your administrator if the problem persists."
|
|
)
|
|
requested = __(
|
|
"This export was requested on %(when)s UTC.", when=_fmt(requested_at)
|
|
)
|
|
return (
|
|
'<html><body style="font-family:Arial,sans-serif;color:#333;">'
|
|
f"<p>{failed}</p>"
|
|
f"<p>{advice}</p>"
|
|
"<hr/>"
|
|
f'<p style="{_FOOTER_STYLE}">{requested}</p>'
|
|
"</body></html>"
|
|
)
|
|
|
|
|
|
def send_export_email(to: str, subject: str, html_content: str) -> None:
|
|
"""Send an export email via the configured SMTP transport."""
|
|
send_email_smtp(
|
|
to=to,
|
|
subject=subject,
|
|
html_content=html_content,
|
|
config=current_app.config,
|
|
)
|