initial: notify-email composite action
Fritzlab-themed CI mail action: HTML (inline styles, dark palette) + plain-text alternative, relayed via mail.fritzlab.net:25 trusted-CIDR rule. Auto-injects repo/branch/SHA/workflow/run from github context. Status drives accent + subject prefix (failure/success/info).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,97 @@
|
||||
# action/notify-email
|
||||
|
||||
Composite Gitea Action that sends a fritzlab-themed email from a CI workflow.
|
||||
Renders an HTML body (with plain-text fallback) styled to match the
|
||||
`websites/fritzlab.net/theme.css` palette, and relays via `mail.fritzlab.net:25`
|
||||
under the trusted-CIDR rule (no SMTP auth — see `k8s-manager` skill `mail.md`).
|
||||
|
||||
Gating is the caller's responsibility (typically `if: failure()`). The action
|
||||
always sends when invoked.
|
||||
|
||||
## Usage
|
||||
|
||||
Minimal failure notification:
|
||||
|
||||
```yaml
|
||||
- name: notify on failure
|
||||
if: failure()
|
||||
uses: https://code.fritzlab.net/action/notify-email@v1
|
||||
with:
|
||||
status: failure
|
||||
summary: "agent deploy failed"
|
||||
```
|
||||
|
||||
With richer context:
|
||||
|
||||
```yaml
|
||||
- name: notify on failure
|
||||
if: failure()
|
||||
uses: https://code.fritzlab.net/action/notify-email@v1
|
||||
with:
|
||||
status: failure
|
||||
subject: "agent deploy failed: ${{ github.sha }}"
|
||||
summary: "Deploy of ${{ github.sha }} to bob@${{ vars.DEPLOY_HOST }} failed"
|
||||
details: |
|
||||
Target host: ${{ vars.DEPLOY_HOST }}
|
||||
Branch: ${{ github.ref_name }}
|
||||
from: agent-ci@fritzlab.net
|
||||
```
|
||||
|
||||
Success notification (e.g. confirm a base-image rebuild cascaded):
|
||||
|
||||
```yaml
|
||||
- if: success()
|
||||
uses: https://code.fritzlab.net/action/notify-email@v1
|
||||
with:
|
||||
status: success
|
||||
summary: "base #${{ github.run_number }} cascaded to runner"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `summary` | yes | — | One-line headline rendered at the top of the email body. |
|
||||
| `status` | no | `info` | `failure` / `success` / `info` — drives accent colour + subject prefix. |
|
||||
| `subject` | no | `summary` | Mail subject after the status prefix. |
|
||||
| `details` | no | — | Multiline preformatted block, rendered in a monospace card. |
|
||||
| `to` | no | `noc@fritzlab.net` | Recipient address. |
|
||||
| `from` | no | `ci@fritzlab.net` | Sender address. Must be permitted by the relay. |
|
||||
| `smtp-host` | no | `mail.fritzlab.net` | SMTP relay host. |
|
||||
| `smtp-port` | no | `25` | SMTP port. |
|
||||
|
||||
## Auto-injected fields
|
||||
|
||||
The action reads the following from `${{ github.* }}` and includes them in
|
||||
both HTML and text bodies — callers do not need to pass these:
|
||||
|
||||
- Repository (linked to Gitea repo page)
|
||||
- Branch (`ref_name`)
|
||||
- Commit (short SHA linked to commit page)
|
||||
- Workflow / job
|
||||
- Run number (+ attempt if `> 1`)
|
||||
- Trigger (`event_name` by `actor`)
|
||||
- "view run" button linking to the Actions run
|
||||
|
||||
## Status styling
|
||||
|
||||
| Status | Subject prefix | Accent |
|
||||
|---|---|---|
|
||||
| `failure` | `[FAILED]` | red (`#fca5a5`) |
|
||||
| `success` | `[OK]` | green (`#86efac`) |
|
||||
| `info` | `[INFO]` | violet (`#7c5cff`) — fritzlab default accent |
|
||||
|
||||
## Headers added
|
||||
|
||||
- `X-Fritzlab-Status: failure | success | info`
|
||||
- `X-Fritzlab-Source: action/notify-email`
|
||||
- `X-Fritzlab-Repo: <owner>/<repo>`
|
||||
|
||||
These can be used as Sieve filters in Stalwart to route or tag CI mail.
|
||||
|
||||
## Why HTML in a CI email
|
||||
|
||||
Email clients strip `<style>` blocks and `@import` and cannot fetch web fonts,
|
||||
so the fritzlab palette is inlined per-element. Dark-only — fritzlab brand has
|
||||
no light variant. The plain-text alternative covers terminal mail readers and
|
||||
spam-filter previews.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
name: Notify by Email
|
||||
description: |
|
||||
Send a fritzlab-themed email from a CI workflow via mail.fritzlab.net.
|
||||
Gating is the caller's responsibility (typically `if: failure()`); the
|
||||
action always sends when invoked. Auto-injects repo, branch, SHA, workflow,
|
||||
and run URL — caller supplies subject, summary, and optional details.
|
||||
inputs:
|
||||
to:
|
||||
description: Recipient address.
|
||||
required: false
|
||||
default: noc@fritzlab.net
|
||||
from:
|
||||
description: Sender address. Must be permitted by mail.fritzlab.net relay rules.
|
||||
required: false
|
||||
default: ci@fritzlab.net
|
||||
status:
|
||||
description: failure | success | info — drives accent color and subject prefix.
|
||||
required: false
|
||||
default: info
|
||||
subject:
|
||||
description: Mail subject (status prefix is added automatically). Defaults to summary when empty.
|
||||
required: false
|
||||
default: ''
|
||||
summary:
|
||||
description: One-line headline rendered at the top of the email body.
|
||||
required: true
|
||||
details:
|
||||
description: Optional multiline preformatted block (rendered in a monospace card). Leave empty to omit.
|
||||
required: false
|
||||
default: ''
|
||||
smtp-host:
|
||||
description: SMTP relay host.
|
||||
required: false
|
||||
default: mail.fritzlab.net
|
||||
smtp-port:
|
||||
description: SMTP port (relay is on 25, trusted-CIDR, no auth).
|
||||
required: false
|
||||
default: '25'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Send notification
|
||||
shell: bash
|
||||
env:
|
||||
NOTIFY_TO: ${{ inputs.to }}
|
||||
NOTIFY_FROM: ${{ inputs.from }}
|
||||
NOTIFY_STATUS: ${{ inputs.status }}
|
||||
NOTIFY_SUBJECT: ${{ inputs.subject }}
|
||||
NOTIFY_SUMMARY: ${{ inputs.summary }}
|
||||
NOTIFY_DETAILS: ${{ inputs.details }}
|
||||
NOTIFY_SMTP_HOST: ${{ inputs.smtp-host }}
|
||||
NOTIFY_SMTP_PORT: ${{ inputs.smtp-port }}
|
||||
GH_SERVER_URL: ${{ github.server_url }}
|
||||
GH_REPOSITORY: ${{ github.repository }}
|
||||
GH_REF_NAME: ${{ github.ref_name }}
|
||||
GH_SHA: ${{ github.sha }}
|
||||
GH_WORKFLOW: ${{ github.workflow }}
|
||||
GH_JOB: ${{ github.job }}
|
||||
GH_RUN_ID: ${{ github.run_id }}
|
||||
GH_RUN_NUMBER: ${{ github.run_number }}
|
||||
GH_RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
GH_ACTOR: ${{ github.actor }}
|
||||
GH_EVENT_NAME: ${{ github.event_name }}
|
||||
run: python3 ${{ github.action_path }}/scripts/send.py
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and send a fritzlab-themed CI notification email.
|
||||
|
||||
Reads inputs from environment variables (set by action.yaml). Delivers a
|
||||
multipart/alternative message (plain text + inline-styled HTML) over plain SMTP
|
||||
to mail.fritzlab.net, which relays via trusted-CIDR rules (see mail.md).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import sys
|
||||
from email.message import EmailMessage
|
||||
from html import escape
|
||||
from typing import Iterable
|
||||
|
||||
# fritzlab tokens (mirror websites/fritzlab.net/theme.css). Inlined here because
|
||||
# email clients strip <style>, drop @import, and cannot fetch web fonts.
|
||||
CANVAS = "#0a0a0a"
|
||||
SURFACE = "#111113"
|
||||
BORDER = "#1f1f23"
|
||||
BORDER_STRONG = "#2a2a2e"
|
||||
TEXT = "#ededed"
|
||||
MUTED = "#888888"
|
||||
MUTED_STRONG = "#a8a8a8"
|
||||
|
||||
ACCENT_VIOLET = "#7c5cff"
|
||||
ERROR_FG = "#fca5a5"
|
||||
ERROR_BG = "#2a0f0f"
|
||||
ERROR_BORDER = "#4a1818"
|
||||
OK_FG = "#86efac" # not in theme.css; added for HTML email status pills
|
||||
OK_BORDER = "#1f3a2a"
|
||||
OK_BG = "#0f1f15"
|
||||
|
||||
FONT_SANS = (
|
||||
"'Geist', -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', "
|
||||
"Helvetica, Arial, sans-serif"
|
||||
)
|
||||
FONT_MONO = "'Geist Mono', ui-monospace, 'JetBrains Mono', Menlo, monospace"
|
||||
|
||||
|
||||
def status_palette(status: str) -> dict[str, str]:
|
||||
"""Return colour + label tokens for a given status string."""
|
||||
s = (status or "info").strip().lower()
|
||||
if s == "failure":
|
||||
return {
|
||||
"key": "failure",
|
||||
"label": "FAILED",
|
||||
"fg": ERROR_FG,
|
||||
"bg": ERROR_BG,
|
||||
"border": ERROR_BORDER,
|
||||
"subject_prefix": "[FAILED]",
|
||||
}
|
||||
if s == "success":
|
||||
return {
|
||||
"key": "success",
|
||||
"label": "OK",
|
||||
"fg": OK_FG,
|
||||
"bg": OK_BG,
|
||||
"border": OK_BORDER,
|
||||
"subject_prefix": "[OK]",
|
||||
}
|
||||
return {
|
||||
"key": "info",
|
||||
"label": "INFO",
|
||||
"fg": ACCENT_VIOLET,
|
||||
"bg": "rgba(124, 92, 255, 0.08)",
|
||||
"border": "#2a2050",
|
||||
"subject_prefix": "[INFO]",
|
||||
}
|
||||
|
||||
|
||||
def required(name: str) -> str:
|
||||
v = os.environ.get(name, "").strip()
|
||||
if not v:
|
||||
print(f"FATAL: required env var {name} is empty", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def fact_rows(items: Iterable[tuple[str, str, str]]) -> tuple[str, str]:
|
||||
"""Build the HTML <table> rows and plain-text lines for the fact list.
|
||||
|
||||
Each item is (label, text_value, html_value). Items whose text_value is
|
||||
empty are skipped — the HTML/text outputs stay in lockstep.
|
||||
"""
|
||||
html_rows: list[str] = []
|
||||
text_lines: list[str] = []
|
||||
for label, text_value, html_value in items:
|
||||
if not text_value:
|
||||
continue
|
||||
html_rows.append(
|
||||
f'<tr>'
|
||||
f'<td style="color:{MUTED};font-family:{FONT_MONO};font-size:12px;'
|
||||
f'padding:4px 24px 4px 0;vertical-align:top;white-space:nowrap;'
|
||||
f'text-transform:uppercase;letter-spacing:0.12em;">{escape(label)}</td>'
|
||||
f'<td style="color:{TEXT};font-family:{FONT_SANS};font-size:14px;'
|
||||
f'padding:4px 0;line-height:1.5;">{html_value}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
text_lines.append(f"{label:<11} {text_value}")
|
||||
return "\n".join(html_rows), "\n".join(text_lines)
|
||||
|
||||
|
||||
def render(
|
||||
*,
|
||||
palette: dict[str, str],
|
||||
summary: str,
|
||||
details: str,
|
||||
facts: list[tuple[str, str]],
|
||||
run_url: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (html, text) bodies."""
|
||||
rows_html, rows_text = fact_rows(facts)
|
||||
|
||||
pill = (
|
||||
f'<span style="display:inline-block;font-family:{FONT_MONO};'
|
||||
f'font-size:11px;font-weight:600;letter-spacing:0.14em;'
|
||||
f'padding:4px 10px;border-radius:6px;'
|
||||
f'color:{palette["fg"]};background:{palette["bg"]};'
|
||||
f'border:1px solid {palette["border"]};text-transform:uppercase;">'
|
||||
f'{palette["label"]}</span>'
|
||||
)
|
||||
|
||||
details_block_html = ""
|
||||
if details.strip():
|
||||
details_block_html = f"""
|
||||
<tr><td style="padding:8px 32px 0 32px;">
|
||||
<div style="font-family:{FONT_MONO};font-size:11px;color:{MUTED};
|
||||
text-transform:uppercase;letter-spacing:0.14em;
|
||||
margin:18px 0 8px 0;">details</div>
|
||||
<pre style="margin:0;padding:14px 16px;background:{CANVAS};
|
||||
border:1px solid {BORDER};border-radius:8px;
|
||||
color:{MUTED_STRONG};font-family:{FONT_MONO};
|
||||
font-size:12px;line-height:1.55;white-space:pre-wrap;
|
||||
word-break:break-word;overflow-x:auto;">{escape(details)}</pre>
|
||||
</td></tr>
|
||||
"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{escape(summary)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:{CANVAS};color:{TEXT};
|
||||
font-family:{FONT_SANS};-webkit-font-smoothing:antialiased;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0" style="background:{CANVAS};padding:32px 16px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0"
|
||||
border="0" style="max-width:600px;width:100%;background:{SURFACE};
|
||||
border:1px solid {BORDER};border-radius:12px;
|
||||
overflow:hidden;">
|
||||
<tr><td style="padding:28px 32px 8px 32px;">
|
||||
{pill}
|
||||
<h1 style="margin:14px 0 0 0;font-family:{FONT_SANS};font-size:20px;
|
||||
font-weight:600;letter-spacing:-0.015em;color:{TEXT};
|
||||
line-height:1.35;">{escape(summary)}</h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding:20px 32px 8px 32px;">
|
||||
<hr style="border:0;border-top:1px solid {BORDER};margin:0 0 16px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width:100%;">
|
||||
{rows_html}
|
||||
</table>
|
||||
</td></tr>
|
||||
{details_block_html}
|
||||
<tr><td style="padding:24px 32px 28px 32px;">
|
||||
<a href="{escape(run_url)}"
|
||||
style="display:inline-block;font-family:{FONT_SANS};font-size:14px;
|
||||
font-weight:500;color:{TEXT};text-decoration:none;
|
||||
padding:11px 22px;border:1px solid {BORDER_STRONG};
|
||||
border-radius:8px;background:transparent;">
|
||||
view run <span style="color:{palette['fg']};">→</span>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 24px 32px;">
|
||||
<div style="font-family:{FONT_MONO};font-size:11px;color:{MUTED};
|
||||
border-top:1px solid {BORDER};padding-top:14px;
|
||||
letter-spacing:0.02em;">
|
||||
sent by <span style="color:{MUTED_STRONG};">action/notify-email</span>
|
||||
· mail.fritzlab.net
|
||||
</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text = (
|
||||
f"[{palette['label']}] {summary}\n"
|
||||
f"{'-' * 64}\n"
|
||||
f"{rows_text}\n"
|
||||
)
|
||||
if details.strip():
|
||||
text += f"\nDetails:\n{details.rstrip()}\n"
|
||||
text += f"\nRun: {run_url}\n"
|
||||
return html, text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
summary = required("NOTIFY_SUMMARY")
|
||||
to_addr = required("NOTIFY_TO")
|
||||
from_addr = required("NOTIFY_FROM")
|
||||
smtp_host = required("NOTIFY_SMTP_HOST")
|
||||
smtp_port = int(required("NOTIFY_SMTP_PORT"))
|
||||
subject_in = os.environ.get("NOTIFY_SUBJECT", "").strip()
|
||||
details = os.environ.get("NOTIFY_DETAILS", "")
|
||||
|
||||
palette = status_palette(os.environ.get("NOTIFY_STATUS", "info"))
|
||||
|
||||
server_url = os.environ.get("GH_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GH_REPOSITORY", "")
|
||||
sha = os.environ.get("GH_SHA", "")
|
||||
short_sha = sha[:8] if sha else ""
|
||||
run_id = os.environ.get("GH_RUN_ID", "")
|
||||
run_url = f"{server_url}/{repo}/actions/runs/{run_id}" if server_url and repo and run_id else ""
|
||||
|
||||
commit_html = ""
|
||||
if sha:
|
||||
if server_url and repo:
|
||||
commit_html = (
|
||||
f'<a href="{escape(server_url)}/{escape(repo)}/commit/{escape(sha)}" '
|
||||
f'style="color:{TEXT};text-decoration:none;'
|
||||
f'border-bottom:1px dotted {MUTED};font-family:{FONT_MONO};'
|
||||
f'font-size:12px;">{escape(short_sha)}</a>'
|
||||
)
|
||||
else:
|
||||
commit_html = f'<span style="font-family:{FONT_MONO};font-size:12px;">{escape(short_sha)}</span>'
|
||||
|
||||
repo_html = ""
|
||||
if repo:
|
||||
if server_url:
|
||||
repo_html = (
|
||||
f'<a href="{escape(server_url)}/{escape(repo)}" '
|
||||
f'style="color:{TEXT};text-decoration:none;'
|
||||
f'border-bottom:1px dotted {MUTED};">{escape(repo)}</a>'
|
||||
)
|
||||
else:
|
||||
repo_html = escape(repo)
|
||||
|
||||
ref_name = os.environ.get("GH_REF_NAME", "")
|
||||
workflow = os.environ.get("GH_WORKFLOW", "")
|
||||
job = os.environ.get("GH_JOB", "")
|
||||
run_number = os.environ.get("GH_RUN_NUMBER", "")
|
||||
run_attempt = os.environ.get("GH_RUN_ATTEMPT", "")
|
||||
actor = os.environ.get("GH_ACTOR", "")
|
||||
event_name = os.environ.get("GH_EVENT_NAME", "")
|
||||
|
||||
# Workflow line: include job only if it differs from the workflow name
|
||||
# (composite-action runs often have workflow == job, e.g. "deploy / deploy").
|
||||
if workflow and job and job != workflow:
|
||||
workflow_text = f"{workflow} / {job}"
|
||||
workflow_html = f"{escape(workflow)} <span style=\"color:{MUTED};\">/</span> {escape(job)}"
|
||||
elif workflow:
|
||||
workflow_text = workflow
|
||||
workflow_html = escape(workflow)
|
||||
else:
|
||||
workflow_text = workflow_html = ""
|
||||
|
||||
run_text = ""
|
||||
run_html = ""
|
||||
if run_number:
|
||||
run_text = f"#{run_number}"
|
||||
run_html = f"#{escape(run_number)}"
|
||||
if run_attempt and run_attempt != "1":
|
||||
run_text += f" (attempt {run_attempt})"
|
||||
run_html += f" (attempt {escape(run_attempt)})"
|
||||
|
||||
trigger_text = ""
|
||||
trigger_html = ""
|
||||
if event_name:
|
||||
trigger_text = event_name + (f" by {actor}" if actor else "")
|
||||
trigger_html = escape(event_name) + (f" by {escape(actor)}" if actor else "")
|
||||
|
||||
facts: list[tuple[str, str, str]] = [
|
||||
("Repository", repo, repo_html),
|
||||
("Branch", ref_name, escape(ref_name) if ref_name else ""),
|
||||
("Commit", short_sha, commit_html),
|
||||
("Workflow", workflow_text, workflow_html),
|
||||
("Run", run_text, run_html),
|
||||
("Trigger", trigger_text, trigger_html),
|
||||
]
|
||||
|
||||
html, text = render(
|
||||
palette=palette,
|
||||
summary=summary,
|
||||
details=details,
|
||||
facts=facts,
|
||||
run_url=run_url,
|
||||
)
|
||||
|
||||
msg = EmailMessage()
|
||||
subject_body = subject_in or summary
|
||||
msg["Subject"] = f"{palette['subject_prefix']} {subject_body}"
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
msg["X-Fritzlab-Status"] = palette["key"]
|
||||
msg["X-Fritzlab-Source"] = "action/notify-email"
|
||||
if repo:
|
||||
msg["X-Fritzlab-Repo"] = repo
|
||||
msg.set_content(text)
|
||||
msg.add_alternative(html, subtype="html")
|
||||
|
||||
print(f"notify-email: sending {palette['key']} to {to_addr} via {smtp_host}:{smtp_port}", file=sys.stderr)
|
||||
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as s:
|
||||
s.send_message(msg)
|
||||
print("notify-email: sent", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user