An email at the end of a daily DAG is a simple way to tell people the data is ready. The
EmailOperator sends an HTML message through an SMTP connection, and its subject and body are
templates, so they can include the date and values returned by earlier tasks.
Copy and paste
1. Install the SMTP provider, if it is not installed yet:
pip install apache-airflow-providers-smtp
2. Create the connection. For a server that uses port 587 and STARTTLS:
export AIRFLOW_CONN_SMTP_DEFAULT='{"conn_type": "smtp", "host": "smtp.example.com", "port": 587, "login": "reports@example.com", "password": "app-password", "extra": {"from_email": "reports@example.com", "disable_ssl": true}}'
For a server that uses port 465 and SSL, set "port": 465 and leave out "disable_ssl".
3. Send the report:
import pendulum
from airflow.sdk import dag, task
from airflow.providers.smtp.operators.smtp import EmailOperator
@dag(schedule="0 7 * * *", start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), catchup=False)
def sales_report():
@task
def summarize(ds=None) -> dict:
# query your warehouse here
return {"orders": 1240, "revenue": 58310.5}
summary = summarize()
send = EmailOperator(
task_id="send_report",
to=["sales-team@example.com"],
subject="Daily sales {{ ds }}",
html_content="""
<h2>Sales for {{ ds }}</h2>
<ul>
<li>Orders: {{ ti.xcom_pull(task_ids='summarize')['orders'] }}</li>
<li>Revenue: {{ '{:,.2f}'.format(ti.xcom_pull(task_ids='summarize')['revenue']) }}</li>
</ul>
""",
conn_id="smtp_default",
)
summary >> send
sales_report()
The message for 15 May arrives with the subject Daily sales 2026-05-15 and lists Orders: 1240 and Revenue: 58,310.50.
The connection settings
| Setting | Where | Default |
|---|---|---|
| Sender address | extra.from_email, or from_email= on the operator |
none |
| SSL from the start (port 465) | on, unless extra.disable_ssl is true |
on |
| STARTTLS (port 587) | on, unless extra.disable_tls is true |
on |
| Timeout in seconds | extra.timeout |
30 |
| Attempts on connection errors | extra.retry_limit |
5 |
Port 587 needs "disable_ssl": true: with SSL left on, the client tries to start an SSL session on a
port that expects plain text followed by STARTTLS. Many providers, Gmail included, require an app
password instead of the account password.
Things to know
- New import path.
EmailOperatornow lives in the SMTP provider. The oldairflow.operators.emailpath still works in Airflow 3 but logs a deprecation warning. - Order.
summary >> sendmakes the email wait for the summary. Pulling an XCom in a template does not create that dependency by itself. - Attachments.
files=["/data/reports/sales_{{ ds }}.csv"]attaches files; the list is a template too. - Recipients.
to,ccandbccaccept a string or a list. - Send it even after a failure.
trigger_rule="all_done"on the email task sends the report whatever happened upstream, but it can make the whole run look successful; see trigger rules. - Email on failure. For alerts rather than reports,
send_smtp_notification(to=..., subject=..., html_content=...)fromairflow.providers.smtp.notifications.smtpworks as anon_failure_callback, like the Slack notifier.
Tested with Airflow 3.3.2 and SMTP provider 3.0, against a local SMTP server.