diff --git a/orchestration/scenario_export_orchestrator.py b/orchestration/scenario_export_orchestrator.py index a45f912e7..4166d27c0 100644 --- a/orchestration/scenario_export_orchestrator.py +++ b/orchestration/scenario_export_orchestrator.py @@ -96,9 +96,60 @@ class ScenarioExportOrchestrator: self._exports.put_object(key, data) url = self._exports.generate_presigned_url(key, self._url_ttl_seconds) + subject, body, html_body = self._compose_email( + url=url, sheets=len(named_sheets), rows=row_count + ) + try: + self._email.send( + to=recipient_email, subject=subject, body=body, html_body=html_body + ) + logger.info("scenario_export: emailed %s", recipient_email) + except Exception: + # Best-effort delivery (ADR-0065): the link is also written to + # sub_task.outputs, so an email/transport failure must not lose an + # export that was already built and uploaded. + logger.warning( + "scenario_export: email delivery to %s failed; the link is still " + "recorded on sub_task.outputs", + recipient_email, + exc_info=True, + ) + return ScenarioExportResult( presigned_url=url, export_s3_key=key, sheet_count=len(named_sheets), row_count=row_count, ) + + def _compose_email( + self, *, url: str, sheets: int, rows: int + ) -> tuple[str, str, str]: + """The delivery email (ADR-0059/0065): a subject, a plain-text body + carrying the raw link, and an HTML alternative with a clean download + button so the long presigned URL isn't the visible content.""" + minutes = self._url_ttl_seconds // 60 + sheet_noun = "scenario" if sheets == 1 else "scenarios" + subject = f"Your scenario export is ready ({sheets} {sheet_noun})" + body = ( + "Your scenario export is ready.\n\n" + f"It covers {sheets} {sheet_noun} across {rows} property row(s).\n\n" + f"Download it here (link valid for {minutes} minutes):\n{url}\n" + ) + safe_url = html.escape(url, quote=True) + html_body = ( + '
Your scenario export is ready.
" + f"It covers {sheets} {sheet_noun} across " + f"{rows} property row(s).
" + '' + f'Download export
' + f'This link expires in {minutes} '
+ "minutes. If the button doesn't work, paste this URL into your browser:"
+ f'
{safe_url}