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