Skip to main content

Sending Emails

As of 25.04, the rule framework also allows you to send emails from rules. This functionality can be used like any other function from the rule framework.

The interface for the rule is as follows:

def send_email(self,
to_emails: list[str] | str,
subject: str,
content: str,
bcc_emails: list[str] | str | None = None,
cc_emails: list[str] | str | None = None,
severity: EmailSeverity | str = EmailSeverity.INFO,
attachments: list[Attachment] | Attachment | None = None):
"""
Sends an email using the EmailService.
Args:
to_emails (list[str] | str): The recipient(s) email address(es).
subject (str): The subject of the email.
content (str): The content/body of the email.
bcc_emails (list[str] | str | None): The BCC recipient(s) email address(es).
cc_emails (list[str] | str | None): The CC recipient(s) email address(es).
severity (EmailSeverity | str): The urgency of the email.
attachments (list[Attachment] | Attachment | None): File(s) to attach.
"""

An address may appear in only one of the to, cc and bcc lists — repeating the same address across two of them is rejected.

Severity

As of 26.09, an email can carry a severity so that recipients can tell at a glance how urgent it is:

SeveritySubjectPriority
CRITICAL[CRITICAL] prefixFlagged as high priority
WARNING[WARNING] prefixFlagged as high priority
INFO (default)unchangedunchanged

INFO is the default, so rules written before severities existed keep sending exactly what they sent before. Pass either the EmailSeverity enum or a plain string — the value is matched case-insensitively, and an unrecognised level is rejected when the rule runs.

from ewx_public.enums import EmailSeverity

self.send_email(
to_emails=["operations@example.com"],
subject="Meter 12345 stopped reporting",
content="<p>No data received since 09:00.</p>",
severity=EmailSeverity.CRITICAL,
)

The recipient receives this with the subject [CRITICAL] Meter 12345 stopped reporting.

Attachments

As of 26.09, a rule can attach files to an email so the recipient gets the data directly instead of logging in to fetch it:

from ewx_public.domain.models.attachment import Attachment

self.send_email(
to_emails=["operations@example.com"],
subject="Daily export",
content="<p>Yesterday's readings are attached.</p>",
attachments=[Attachment.from_dataframe(self.dataframe, "readings.csv")],
)

Build an Attachment whichever way suits the data you have:

ConstructorUse for
Attachment.from_dataframe(df, "readings.csv")a DataFrame, written as CSV (the index is included; pass index=False to leave it out)
Attachment.from_text(text, "summary.json")a string the rule built itself, e.g. JSON
Attachment(filename="report.xlsx", content=raw_bytes)anything you already have as bytes

Pass a single Attachment or a list of them. The supported file types are csv, json, pdf, txt, xls, xlsx and xml, and one email may carry at most 10 files totalling 10 MB. The content type is taken from the file extension, so "readings.csv" arrives as text/csv; pass content_type= to override it.

An attachment that cannot be sent — an unsupported file type, a missing filename, empty content, or too much data — is rejected while the rule runs, so the failure is reported against the rule rather than silently producing an email without its file.

Creating each file type

The recipes below build a file inside a rule. They are written around Attachment because this page is about email, but the content they produce is the same content additional_output stores in File Management — so a rule can mail a report, file it, or both, from one buffer.

All seven types can be attached, but only five can be created inside a rule. xls and pdf are accepted so a rule can forward a file it already holds as bytes — for example one it received in the incoming payload — because writing those formats needs libraries that are not available to rules.

TypeCreate in a rule?Build it with
csvyesAttachment.from_dataframe or Attachment.from_text
jsonyesjson.dumps + Attachment.from_text
txtyesAttachment.from_text
xmlyesxml.etree.ElementTree + Attachment
xlsxyesDataFrame.to_excel + Attachment
xlsno — forward onlywrite xlsx instead
pdfno — forward only

CSV

Straight from a DataFrame. The index is written by default, which is normally the timestamp the recipient needs; pass index=False to leave it out:

Attachment.from_dataframe(self.dataframe, "readings.csv")

Or build the text yourself:

csv_content = "timestamp,value\n2026-08-17T00:00:00Z,10.5\n"
Attachment.from_text(csv_content, "readings.csv")

JSON

import json

payload = json.dumps({"datasource": self.datasource.id, "total": 21.7}, indent=2)
Attachment.from_text(payload, "summary.json")

Plain text

Attachment.from_text("No data received since 09:00.\n", "notes.txt")

XML

import xml.etree.ElementTree as ET

root = ET.Element("readings", datasource=self.datasource.id)
for timestamp, value in self.dataframe["E_CONS"].items():
ET.SubElement(root, "reading", timestamp=timestamp.isoformat()).text = str(value)

Attachment(
filename="readings.xml",
content=ET.tostring(root, encoding="utf-8", xml_declaration=True),
)

Excel (xlsx)

Write the workbook to an in-memory buffer and attach the bytes. Both xlsxwriter and openpyxl are available to rules:

import io

frame = self.dataframe.copy()
# Excel cannot store a timezone, and a rule's DataFrame is normally indexed in
# UTC. Drop the timezone (after converting to UTC) or to_excel raises
# "Excel does not support datetimes with timezones".
frame.index = frame.index.tz_convert("UTC").tz_localize(None)

buffer = io.BytesIO()
frame.to_excel(buffer, engine="xlsxwriter", sheet_name="Readings")

self.send_email(
to_emails=["operations@example.com"],
subject="Monthly report",
content="<p>The report is attached.</p>",
attachments=[Attachment(filename="report.xlsx", content=buffer.getvalue())],
)

For several sheets in one workbook, use pd.ExcelWriter:

import io
import pandas as pd

buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
frame.to_excel(writer, sheet_name="Readings")
frame.describe().to_excel(writer, sheet_name="Summary")

Attachment(filename="report.xlsx", content=buffer.getvalue())
note

Always use the .xlsx extension for a workbook you create. Writing the old binary .xls format is not possible — pandas removed the only engine that could — and naming an .xlsx file .xls makes Excel warn about a format mismatch when the recipient opens it.

Make sure to ask the Energyworx support team to see if the EmailService is enabled for your environment. All emails will be sent from the notify@energyworx.org email address.

As of 26.09, an email body can link straight to the entity the alert is about, so the recipient can open it instead of searching for it. Write a placeholder in the content and the platform turns it into a link while the rule runs:

self.send_email(
to_emails=["operations@example.com"],
subject="Meter stopped reporting",
content=(
"<p>No data received since 09:00.</p>"
"<p>Check [[datasource]] and log it on [[taskBoard: 42 | the alerts board]].</p>"
),
)

The recipient receives a body in which both placeholders are ordinary links — the first to the datasource the flow is running on, the second to task board 42 with the words "the alerts board" as the link text.

Placeholders

PlaceholderLinks to
[[datasource]]The datasource the flow is running on
[[datasource: <id>]]A specific datasource
[[taskBoard: <id>]]A task board

Add | <text> before the closing brackets of any of these to choose the link text — [[datasource: meter-12345 | the failing meter]]. Without it the link text is the id.

The entity name is not case sensitive, so [[taskBoard: 42]] and [[taskboard: 42]] are the same placeholder.

Links always carry the namespace the flow runs in, so the recipient lands on the right entity even when they have several namespaces and were last working in another one.

Notes

  • Placeholders work in content only, not in subject — a link in a subject line is not clickable anyway.
  • The double square brackets are deliberate: ${...} would be swallowed by Python's own f-string formatting, and rule bodies are usually built with f-strings. [[...]] behaves the same in an f-string as in a plain string, so you can mix your own values and placeholders in one literal.
  • A placeholder that cannot be resolved — a misspelled entity type, a missing id — is left in the email exactly as you wrote it and recorded as an audit event. The email is still sent: a broken link never costs you the alert.
  • Deep links need a console URL for your environment. If links come through as literal [[...]] text in every email, ask the Energyworx support team to check that your environment is configured for it.

Testing placeholders offline

ewx-public's offline test double expands placeholders exactly as the platform does, so you can assert on the real link, and it collects anything that failed to resolve so a typo fails the test instead of reaching a recipient:

from ewx_public.testing import make_testable, make_datasource

def test_alert_links_to_the_datasource():
rule = make_testable(MyAlertRule, datasource=make_datasource(id="meter-12345"))
rule.apply()

content = rule.backend.emails[0]["content"]
assert "meter-12345" in content
assert rule.backend.link_problems == []

Pass ui_base_url= and namespace= to make_testable() to render the links against your own environment's host and namespace. namespace= takes either a make_namespace(...) object, as the platform passes, or the namespace id as a plain string.

Example of use:

def send_email_response(self, to_emails: list[str], bcc_emails: list[str] | None = None,
cc_emails: list[str] | None = None):
R""" Send an automated email response.

Arguments:
to_emails: The email addresses of the recipients
bcc_emails: The email addresses of the BCC recipients
cc_emails: The email addresses of the CC recipients

"""
content = """<h1>This is an automated response from the Energyworx platform.</h1>"""
self.send_email(
to_emails=to_emails,
subject="Automated Response",
content=content,
bcc_emails=bcc_emails,
cc_emails=cc_emails,
)