Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# na-emailer

Knative function that receives **CloudEvents**, optionally filters them (typically via configuration injected by a **Knative SinkBinding**), renders **Jinja2** templates (subject + plain/html), and sends an email notification.
Knative function that receives **CloudEvents**, optionally filters them (typically via configuration injected by a **Knative SinkBinding**), renders **Jinja2** templates (subject + plain/html) or direct MIME messages, and sends an email notification.

## How it works
- Accepts an incoming HTTP request carrying a CloudEvent (binary or structured).
Expand Down Expand Up @@ -45,6 +45,7 @@ Example value:
Notes:
- Keys must be the exact template filenames the renderer looks up, e.g. `default.subject.j2`, `default.txt.j2`, `default.html.j2`.
- When embedding JSON in YAML (Knative manifests), make sure to quote/escape properly (usually a YAML block scalar is easiest).
- Inline templates will also be defineble via CE data (e.g. `data.templates_inline_json`).

#### Filesystem templates
- `NA_TEMPLATES_DIR`: templates folder.
Expand All @@ -54,9 +55,9 @@ Notes:
### Email
- `NA_EMAIL_CLIENT`: email backend (default `yagmail`).
- `NA_EMAIL_FROM`: optional sender.
- `NA_EMAIL_TO`: comma-separated recipients.
- `NA_EMAIL_CC`, `NA_EMAIL_BCC`: optional.
- `NA_EMAIL_SUBJECT_PREFIX`: optional prefix.
- `NA_EMAIL_TO`: comma-separated recipients, optional is overriden by CE data.
- `NA_EMAIL_CC`, `NA_EMAIL_BCC`: optional, overriden by CE settings.
- `NA_EMAIL_SUBJECT_PREFIX`: optional prefix, overriden by CE settings.
- `NA_DRY_RUN`: `true|false` (if true, renders but doesn’t send).

### Yagmail backend
Expand All @@ -68,6 +69,8 @@ Optional:
- `NA_YAGMAIL_HOST`, `NA_YAGMAIL_PORT`
- `NA_YAGMAIL_SMTP_STARTTLS`, `NA_YAGMAIL_SMTP_SSL`

Note: In case of standard Gmail account the latter settings are not required.

## Templates
Templates are chosen by **base name**.

Expand All @@ -80,6 +83,7 @@ The template context includes:
- `ce`: CloudEvent attributes (plus extensions)
- `data`: the CloudEvent data

Note: If `inline_templates` are provided, the HTML template is prioritized and used for email body. If only the text template is provided, it will be used as the email body.
## Local development
### Option A: run via `start.py` (recommended)
This prints an explicit “Waiting for CloudEvents...” line and defaults to dry-run.
Expand All @@ -99,13 +103,67 @@ functions-framework --target handle --port 8080

### Send a test CloudEvent

Template based example:

```zsh
curl -i http://localhost:8080/ \
-H 'Content-Type: application/cloudevents+json' \
-d '{"specversion":"1.0","id":"1","source":"/local","type":"com.acme.test","datacontenttype":"application/json","data":{"hello":"world"}}'
-d '{
"specversion": "1.0",
"id": "1",
"source": "/local",
"type": "com.acme.test",
"datacontenttype": "application/json",
"emailto": [
"test_to@gmail.com"
],
"emailcc": [
"test_cc@gmail.com"
],
"emailbcc": [
],
"data": {
"name": "Hello world",
"templates_inline_json": {
"default.subject.j2": "Notification [{{ ce.type }}] ",
"default.txt.j2": "Hello {{ data.name }}\n\nCloudEvent: {{ ce.id }}\nType: {{ ce.type }}\nSource: {{ ce.source }}\n",
"default.html.j2": "<!doctype html>\n<html>\n <body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;\">\n <h2>NA Notification</h2>\n <p><b>Name:</b> {{ data.name }}</p>\n\n <h3>CloudEvent</h3>\n <ul>\n <li><b>id</b>: {{ ce.id }}</li>\n <li><b>type</b>: {{ ce.type }}</li>\n <li><b>source</b>: {{ ce.source }}</li>\n <li><b>subject</b>: {{ ce.subject }}</li>\n <li><b>time</b>: {{ ce.time }}</li>\n </ul>\n\n <h3>Data</h3>\n <pre style=\"background:#f6f8fa;padding:12px;border-radius:6px;\">{{ data | tojson(indent=2) }}</pre>\n </body>\n</html>\n"
}
}
}
'

```
NOTE:
- Only `emailto` is required.
- Email subject will be rendered with the provided `default.subject.j2` template, the default version is also presented in the above example.
- The email body will be rendered with the provided `default.html.j2` template, if not provided it will fallback to `default.txt.j2`. If neither of them is provided, the renderer will use a built-in default templates.


MIME example:

```zsh
curl --location --request GET 'http://localhost:8080/' \
--header 'Content-Type: application/json' \
--data-raw '{
"specversion": "1.0",
"id": "mime-1",
"source": "/local",
"type": "com.acme.test_mime",
"emailto": [
"test_to@gmail.com"
],
"datacontenttype": "mime/multipart",
"data": "Subject: Example multipart/alternative 12 message\nDate: ...\nMessage-Id: ...\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"...\"\n\n--...\nContent-Type: multipart/alternative; boundary=\"...\"\n\n--...\nContent-Type: text/plain; charset=\"utf-8\"\n.TEST.\n"

}'
```

Note:
- In this case the email content is directly provided in the `data` field as a raw MIME message. The renderer will parse the MIME message and send it as-is, ignoring any templates. The `emailto` field is still required to determine the recipients and will be added automatically.
- Please note that the `datacontenttype` must be set to `mime/multipart` to indicate that the email content is provided as a raw MIME message, otherwise the renderer will attempt to process the content as a regular CloudEvent data and render templates, which may lead to unexpected results.

## Notes
## Final Notes
This repo is intentionally small and focused (core runtime + tests).

Add new email backends by implementing `app.clients.base.EmailClient` and wiring it in `app.clients.factory.create_email_client`.
54 changes: 51 additions & 3 deletions app/clients/yagmail_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
from ..config import Settings
from ..models import EmailMessage
from .base import EmailClient
import smtplib
from email import policy
from email.parser import BytesParser, Parser


class YagmailClient(EmailClient):
def __init__(self, settings: Settings):
if not settings.yagmail_user or not settings.yagmail_password:
raise ValueError("NA_YAGMAIL_USER and NA_YAGMAIL_PASSWORD are required for yagmail client")

self._settings = settings
kwargs: dict[str, Any] = {"user": settings.yagmail_user, "password": settings.yagmail_password}
if settings.yagmail_host:
kwargs["host"] = settings.yagmail_host
Expand All @@ -23,12 +26,57 @@ def __init__(self, settings: Settings):

self._smtp = yagmail.SMTP(**kwargs)

def _normalize_raw_mime(self, raw_mime: str) -> bytes:
raw = raw_mime.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8", errors="replace")

try:
msg = BytesParser(policy=policy.SMTP).parsebytes(raw)
except Exception:
msg = Parser(policy=policy.SMTP).parsestr(raw_mime)

if not msg.get("From"):
msg["From"] = self._settings.yagmail_user
if not msg.get("MIME-Version"):
msg["MIME-Version"] = "1.0"

return msg.as_bytes(policy=policy.SMTP)

def _send_raw_via_smtplib(self, raw_mime: str, recipients: list[str]) -> None:
host = self._settings.yagmail_host or "smtp.gmail.com"
port = self._settings.yagmail_port or (465 if self._settings.yagmail_smtp_ssl else 587)

if self._settings.yagmail_smtp_ssl:
server: smtplib.SMTP = smtplib.SMTP_SSL(host, port)
else:
server = smtplib.SMTP(host, port)

raw_bytes = self._normalize_raw_mime(raw_mime)

try:
server.ehlo()
if (not self._settings.yagmail_smtp_ssl) and (self._settings.yagmail_smtp_starttls is not False):
server.starttls()
server.ehlo()

server.login(self._settings.yagmail_user, self._settings.yagmail_password)
server.sendmail(self._settings.yagmail_user, recipients, raw_bytes)
finally:
try:
server.quit()
except Exception:
server.close()

def send(self, message: EmailMessage) -> None:

if message.raw_mime:
self._send_raw_via_smtplib(message.raw_mime, list(message.to) + (list(message.cc) if message.cc else []) + (list(message.bcc) if message.bcc else []))
return

contents = []
if message.text:
contents.append(message.text)
if message.html:
contents.append(message.html)
elif message.text:
contents.append(message.text)

self._smtp.send(
to=list(message.to),
Expand Down
118 changes: 103 additions & 15 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import logging
import os
import functions_framework
from typing import Any
from cloudevents.http import from_http
from flask import Request
from .clients.factory import create_email_client
Expand Down Expand Up @@ -32,6 +34,7 @@ def _configure_logging(level_name: str) -> None:
if not _LOGGING_CONFIGURED:
logger.info("na-emailer started (ready to receive events)")
_LOGGING_CONFIGURED = True

_configure_logging(os.getenv("NA_LOG_LEVEL", "INFO"))


Expand All @@ -45,6 +48,9 @@ def _ctx_from_cloudevent(ce) -> EventContext:
"subject",
"time",
"dataschema",
"emailto",
"emailcc",
"emailbcc",
"datacontenttype",
"data",
}
Expand All @@ -56,19 +62,51 @@ def _ctx_from_cloudevent(ce) -> EventContext:
attrs = {}

extensions = {k: v for k, v in attrs.items() if k not in spec_keys}

return EventContext(
id=ce["id"],
source=ce["source"],
type=ce["type"],
subject=ce.get("subject"),
time=ce.get("time"),
dataschema=ce.get("dataschema"),
emailto=ce.get("emailto"),
emailcc=ce.get("emailcc"),
emailbcc=ce.get("emailbcc"),
datacontenttype=ce.get("datacontenttype"),
data=ce.get("data"),
data=ce.data,
extensions=extensions,
)

def _parse_recipients(value) -> list[str]:
if value in (None, ""):
return []
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()]
if isinstance(value, str):
return [v.strip() for v in value.split(",") if v.strip()]
return []


def _recipients_from_event(ctx: EventContext):
#support both ce-email_to and email_to in data for maximum flexibility.
return _parse_recipients(ctx.emailto), _parse_recipients(ctx.emailcc), _parse_recipients(ctx.emailbcc)

def _extract_raw_mime(ctx: EventContext) -> str | None:
data: Any = ctx.data
if data is None:
return None
if isinstance(data, (bytes, bytearray)):
try:
return bytes(data).decode("utf-8", errors="replace")
except Exception:
return None
if isinstance(data, str):
return data
if isinstance(data, dict):
raw = data.get("raw_mime") or data.get("mime") or data.get("message")
if isinstance(raw, str) and raw.strip():
return raw
return None

@functions_framework.http
def handle(request: Request):
Expand All @@ -79,14 +117,14 @@ def handle(request: Request):

try:
ce = from_http(request.headers, request.get_data())
except Exception:
logger.exception("Failed to parse incoming CloudEvent")
except Exception as e:
logger.exception(f"Failed to parse incoming CloudEvent: {e}")
return ("Invalid CloudEvent", 400)

try:
ctx = _ctx_from_cloudevent(ce)
except Exception:
logger.exception("Failed to translate CloudEvent into internal context")
except Exception as e:
logger.exception(f"Failed to translate CloudEvent into internal context: {e}")
return ("Invalid CloudEvent", 400)

logger.info(
Expand All @@ -101,29 +139,79 @@ def handle(request: Request):
)
return ("", 204)

event_recipients, event_cc, event_bcc =_recipients_from_event(ctx)

recipients = event_recipients or settings.email_to
email_cc = event_cc or settings.email_cc
email_bcc = event_bcc or settings.email_bcc

#MIME multipart mode: bypass templating and send raw MIME as-is.
raw_mime = None
if (ctx.datacontenttype or "").strip().lower() in {"mimemultipart", "mime/multipart", "multipart/mixed"}:
raw_mime = _extract_raw_mime(ctx)
if not raw_mime:
return ("Missing raw MIME payload in CloudEvent data", 400)

msg = EmailMessage(
subject="",
text=None,
html=None,
sender=settings.email_from,
to=recipients,
cc=email_cc,
bcc=email_bcc,
headers={
"X-CloudEvent-ID": ctx.id,
"X-CloudEvent-Type": ctx.type,
"X-CloudEvent-Source": ctx.source,
},
raw_mime=raw_mime,
)

if not (list(msg.to) or list(msg.cc) or list(msg.bcc)):
logger.warning("No recipients configured; skipping send", extra={"ce_id": ctx.id})
return ("", 202)

if settings.dry_run:
logger.info("DRY RUN raw MIME email (not sent)", extra={"ce_id": ctx.id})
return ("", 202)

try:
client = create_email_client(settings)
client.send(msg)
except Exception as e:
logger.exception(f"Failed to send raw MIME email: {e}")
return ("Email send failed", 502)

return ("", 202)

# if ce contains inline templates
if ctx.data and isinstance(ctx.data, dict) and "templates_inline_json" in ctx.data:
settings.templates_inline_json = ctx.data["templates_inline_json"]

try:
renderer = TemplateRenderer(settings)
subject, text, html = renderer.render(ctx)
except Exception:
logger.exception("Failed to render email templates")
except Exception as e:
logger.exception(f"Failed to render email templates: {e}")
return ("Template rendering failed", 500)

subject = f"{settings.email_subject_prefix}{subject}" if settings.email_subject_prefix else subject

msg = EmailMessage(
subject=subject,
text=text,
html=html,
sender=settings.email_from,
to=settings.email_to,
cc=settings.email_cc,
bcc=settings.email_bcc,
to=recipients,
cc=email_cc,
bcc=email_bcc,
headers={
"X-CloudEvent-ID": ctx.id,
"X-CloudEvent-Type": ctx.type,
"X-CloudEvent-Source": ctx.source,
},
raw_mime=raw_mime,
)
logger.info(f"Prepared email message: subject='{msg.subject}', to={msg.to}, cc={msg.cc}, bcc={msg.bcc}")

if not msg.to:
logger.warning(
Expand All @@ -139,8 +227,8 @@ def handle(request: Request):
try:
client = create_email_client(settings)
client.send(msg)
except Exception:
logger.exception("Failed to send email")
except Exception as e:
logger.exception(f"Failed to send email: {e}")
return ("Email send failed", 502)

logger.info("Email queued/sent", extra={"to": list(msg.to), "subject": msg.subject, "ce_id": ctx.id})
Expand Down
Loading