From dcf257d887340d4ff15944db5ca0d756db981a83 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Mon, 19 Oct 2026 08:40:32 +0000 Subject: [PATCH] feat(mail-templates): render ACKnowledge SOC emails from markdown (1.0.0) render-mail-template turns a template, a subject and a markdown description into the dark ACKnowledge SOC mail (header badge, optional callout and action button, signature with the logo, SOC or client footer, fr/en). Nothing is sent: subject, html_body and text_body go to exchange-send-mail or mail-sender, which embed the data-URI logo. Eight templates (sla_breach, incident_notification, incident_update, incident_closure, action_required, escalation, threat_bulletin, info). The markdown covers GFM tables, code blocks and {{level}} pills; raw HTML in the inputs is escaped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41 --- integrations/mail-templates/manifest.yaml | 69 +++ .../scripts/render_mail_template.py | 584 ++++++++++++++++++ .../mail-templates/scripts/test_connection.py | 27 + 3 files changed, 680 insertions(+) create mode 100644 integrations/mail-templates/manifest.yaml create mode 100644 integrations/mail-templates/scripts/render_mail_template.py create mode 100644 integrations/mail-templates/scripts/test_connection.py diff --git a/integrations/mail-templates/manifest.yaml b/integrations/mail-templates/manifest.yaml new file mode 100644 index 0000000..46f96c5 --- /dev/null +++ b/integrations/mail-templates/manifest.yaml @@ -0,0 +1,69 @@ +id: mail_templates +name: Mail Templates (ACKnowledge) +version: 1.0.0 +description: "Turns a subject and a markdown description into an ACKnowledge SOC email (dark theme, header badge, signature with the logo, footer) — nothing is sent: chain the output into exchange-send-mail or mail-sender (`subject` → subject, `html_body` → html_body, `text_body` → body). Eight templates, each with its own badge and colour: sla_breach, incident_notification (colour follows `severity`), incident_update, incident_closure, action_required, escalation, threat_bulletin, info. The description takes markdown: headings (## is the blue capitalised section title), bold, italic, links, lists, quotes, rules, GFM tables, `code` and ``` blocks (hashes, IPs, command lines), and level pills `{{critical}}` `{{high}}` `{{medium}}` `{{low}}` `{{info}}` `{{ok}}` — `{{high:Élevée}}` changes the label. Raw HTML in the inputs is escaped, so alert data can be interpolated safely. The logo is an inline data-URI PNG: exchange-send-mail and mail-sender embed it; the Gmail integration does not, and the logo is lost there. No connection, no secret; stdlib-only." +changelog: "1.0.0 — Initial release: render-mail-template (8 templates, markdown description with tables, code and level pills, optional callout, action button, case reference and severity, SOC or client footer, French or English) and a self-test." +category: email + +# Nothing to connect to. The only setting names the bot in the SOC footer +# ("generated automatically by the bot"). +config_schema: + properties: + bot_name: + type: string + description: "Bot named in the footer of SOC mails (default Riposte)" + default: Riposte + required: [] + +commands: + - id: render_mail_template + name: mail-render-template + description: "Render an ACKnowledge SOC email from a template, a subject and a markdown description. Returns `subject` (prefixed with [case_ref] when case_ref is given and not already in it), `html_body` (table layout with inline styles, for Outlook and webmail), `text_body` (the plain-text part) and `html_bytes`. Sends nothing: pass the outputs to exchange-send-mail or mail-sender." + risk: read + inputs_schema: + properties: + template: + type: string + enum: [sla_breach, incident_notification, incident_update, incident_closure, action_required, escalation, threat_bulletin, info] + description: "Layout: sla_breach (red, SLA ALERT), incident_notification (colour of `severity`), incident_update (blue, STATUS UPDATE), incident_closure (green, CLOSED), action_required (orange), escalation (magenta), threat_bulletin (violet), info (grey, default)" + subject: { type: string, description: "Subject, also shown as the title of the mail" } + description: { type: string, description: "Body in markdown — ## section titles, **bold**, lists, tables, `code`, ``` blocks, {{medium}} pills" } + callout_text: { type: string, description: "Highlighted box above the description (markdown). Empty: no box" } + callout_title: { type: string, description: "Title of the box (default: the template badge, e.g. ⚠ SLA ALERT)" } + button_label: { type: string, description: "Action button text, e.g. Open the case in Riposte (needs button_url)" } + button_url: { type: string, description: "Action button link (http or https)" } + case_ref: { type: string, description: "Case or ticket reference, shown under the title and prefixed to the subject" } + severity: + type: string + enum: [critical, high, medium, low, info] + description: "Severity pill under the title; for incident_notification it sets the badge colour and label" + audience: + type: string + enum: [soc, client] + description: "Footer: soc (generated by the bot, default) or client (confidentiality notice)" + lang: + type: string + enum: [fr, en] + description: "Language of the badges, date and footer (default fr)" + date: + type: string + description: "ISO 8601 date shown under the title, in UTC (default: now)" + required: [template, subject, description] + outputs_schema: + properties: + subject: { type: string } + html_body: { type: string } + text_body: { type: string } + template: { type: string } + lang: { type: string } + audience: { type: string } + html_bytes: { type: number } + + - id: test_connection + name: mail-templates-test + description: "Render every template with a sample description, without sending anything (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/mail-templates/scripts/render_mail_template.py b/integrations/mail-templates/scripts/render_mail_template.py new file mode 100644 index 0000000..7cf0bd6 --- /dev/null +++ b/integrations/mail-templates/scripts/render_mail_template.py @@ -0,0 +1,584 @@ +import colorsys, datetime, html, json, os, re, sys + +# White ACKnowledge wordmark, 320x76 PNG (shown at 160x38). A data-URI image is +# turned into an inline attachment by exchange-send-mail and mail-sender; mail +# clients block SVG, hence the PNG. +LOGO_PNG_B64 = "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" + +OUTER = "#55555a" +PAGE = "#1e1e1e" +HEADER = "#4a4a7c" +HEADER_RULE = "#5d5d8f" +PANEL = "#262626" +TEXT = "#e6e6e6" +MUTED = "#a9a9b8" +BLUE = "#4f9ae0" +BLUE_RULE = "#2f7fc8" +SIGNATURE = "#d8dcff" +WHITE = "#ffffff" +FONT = "'Exo 2','Segoe UI',Helvetica,Arial,sans-serif" +BODY_FONT = "'Segoe UI',Helvetica,Arial,sans-serif" +MONO = "Consolas,'SFMono-Regular',Menlo,'Courier New',monospace" + +LEVELS = { + "critical": "#b3261e", + "high": "#c2410c", + "medium": "#7a3f12", + "low": "#2e6b3a", + "info": "#455a64", + "ok": "#1e7d4f", +} +LEVEL_ALIASES = { + "critique": "critical", "haute": "high", "élevée": "high", "elevee": "high", "élevé": "high", + "moyenne": "medium", "moyen": "medium", "faible": "low", "basse": "low", + "informational": "info", "information": "info", +} +LEVEL_LABELS = { + "fr": {"critical": "CRITIQUE", "high": "ÉLEVÉE", "medium": "MOYENNE", "low": "FAIBLE", "info": "INFO", "ok": "OK"}, + "en": {"critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW", "info": "INFO", "ok": "OK"}, +} + +TEMPLATES = { + "sla_breach": {"color": "#a52a22", "icon": "⚠", "fr": "ALERTE SLA", "en": "SLA ALERT"}, + "incident_notification": {"color": None, "icon": "●", "fr": "INCIDENT", "en": "INCIDENT"}, + "incident_update": {"color": "#1f6fb2", "icon": "↻", "fr": "POINT DE SITUATION", "en": "STATUS UPDATE"}, + "incident_closure": {"color": "#1e7d4f", "icon": "✓", "fr": "INCIDENT CLÔTURÉ", "en": "INCIDENT CLOSED"}, + "action_required": {"color": "#b8620a", "icon": "⚑", "fr": "ACTION REQUISE", "en": "ACTION REQUIRED"}, + "escalation": {"color": "#a8246f", "icon": "▲", "fr": "ESCALADE", "en": "ESCALATION"}, + "threat_bulletin": {"color": "#6b3fa0", "icon": "◆", "fr": "BULLETIN DE VEILLE", "en": "THREAT BULLETIN"}, + "info": {"color": "#55606e", "icon": "", "fr": "INFORMATION", "en": "INFORMATION"}, +} + +STRINGS = { + "fr": { + "months": None, + "at": "à", + "soc_footer": "Ce message est généré automatiquement par le bot {bot} — ACKnowledge SOC • Cybersécurité • Cloud • DevSecOps", + "soc_footer_text": "Ce message est généré automatiquement par le bot {bot} — ACKnowledge SOC • Cybersécurité • Cloud • DevSecOps", + "client_footer": "Ce message et ses éventuelles pièces jointes sont confidentiels et destinés exclusivement à leurs destinataires. Si vous l'avez reçu par erreur, merci d'en avertir l'expéditeur et de le supprimer.", + "tagline": "ACKnowledge SOC • Cybersécurité • Cloud • DevSecOps", + }, + "en": { + "months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "at": "", + "soc_footer": "This message was generated automatically by the {bot} bot — ACKnowledge SOC • Cybersecurity • Cloud • DevSecOps", + "soc_footer_text": "This message was generated automatically by the {bot} bot — ACKnowledge SOC • Cybersecurity • Cloud • DevSecOps", + "client_footer": "This message and any attachments are confidential and intended solely for their addressees. If you received it in error, please notify the sender and delete it.", + "tagline": "ACKnowledge SOC • Cybersecurity • Cloud • DevSecOps", + }, +} + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _s(v): + return "" if v is None else str(v).strip() + + +def _mix(a, b, t): + """a blended toward b by t (0..1), as #rrggbb — solid colours, since Outlook drops rgba().""" + a = [int(a[i:i + 2], 16) for i in (1, 3, 5)] + b = [int(b[i:i + 2], 16) for i in (1, 3, 5)] + return "#" + "".join("%02x" % round(x + (y - x) * t) for x, y in zip(a, b)) + + +def _vivid(c): + """A light, saturated tint of c for text on a dark tinted box.""" + h, _, sat = colorsys.rgb_to_hls(*(int(c[i:i + 2], 16) / 255 for i in (1, 3, 5))) + return "#" + "".join("%02x" % round(x * 255) for x in colorsys.hls_to_rgb(h, 0.64, max(sat, 0.75))) + + +def _level(name): + n = _s(name).lower() + n = LEVEL_ALIASES.get(n, n) + return n if n in LEVELS else None + + +def _pill(level, label): + return ( + '%s' % (LEVELS[level], FONT, label) + ) + + +# --- Markdown --------------------------------------------------------------- +# A small renderer for what a playbook writes in a mail: paragraphs, headings, +# emphasis, links, lists, quotes, rules, GFM tables, code, and {{level}} pills. +# Raw HTML is escaped, never passed through: the text often carries alert data. + +_P = 'style="margin:0 0 14px;font-family:%s;font-size:15px;line-height:24px;color:%s"' % (BODY_FONT, TEXT) +_TABLE_SEP = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$") +_LIST = re.compile(r"^(\s*)([-*+]|\d{1,3}[.)])\s+(.*)$") +_HR = re.compile(r"^\s{0,3}([-*_])(\s*\1){2,}\s*$") +_HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$") +_FENCE = re.compile(r"^\s{0,3}(```|~~~)") + + +class Inline: + def __init__(self, lang): + self.lang = lang + + def render(self, text): + keep = [] + + def stash(fragment): + keep.append(fragment) + return "\x00%d\x00" % (len(keep) - 1) + + text = re.sub( + r"`([^`]+)`", + lambda m: stash( + '%s' % (MONO, html.escape(m.group(1))) + ), + text, + ) + text = html.escape(text, quote=True) + + def link(label, url): + raw = html.unescape(url) + if not re.match(r"^(https?://|mailto:)", raw, re.I): + return label + return stash('%s' % (url, BLUE, label)) + + text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", lambda m: link(self._emphasis(m.group(1)), m.group(2)), text) + text = re.sub(r"<((?:https?://|mailto:)[^\s&]+)>", lambda m: link(m.group(1), m.group(1)), text) + text = re.sub(r"(?\1', text) + text = re.sub(r"(?\1', text) + text = re.sub(r"(?\1", text) + text = re.sub(r"(?\1", text) + text = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"\1", text) + return text + + +def _cells(line): + line = line.strip() + if line.startswith("|"): + line = line[1:] + if line.endswith("|") and not line.endswith("\\|"): + line = line[:-1] + return [c.strip().replace("\\|", "|") for c in re.split(r"(?") + or _LIST.match(line) or ("|" in line and i + 1 < len(lines) and _TABLE_SEP.match(lines[i + 1])) + ) + + +def markdown(md, lang, accent): + lines = md.replace("\r\n", "\n").replace("\r", "\n").split("\n") + return _blocks(lines, Inline(lang), accent) + + +def _blocks(lines, inl, accent): + out = [] + i = 0 + n = len(lines) + while i < n: + line = lines[i] + if not line.strip(): + i += 1 + continue + + m = _FENCE.match(line) + if m: + fence = m.group(1) + body = [] + i += 1 + while i < n and not lines[i].strip().startswith(fence): + body.append(lines[i]) + i += 1 + i += 1 + out.append( + '' + '
' + '
%s
' % (MONO, TEXT, html.escape("\n".join(body))) + ) + continue + + m = _HEADING.match(line) + if m: + level = len(m.group(1)) + content = inl.render(m.group(2)) + if level <= 2: + out.append( + '

%s

' + % (BLUE_RULE, FONT, BLUE, content) + ) + else: + out.append( + '

%s

' + % (FONT, content) + ) + i += 1 + continue + + if _HR.match(line): + out.append('
') + i += 1 + continue + + if "|" in line and i + 1 < n and _TABLE_SEP.match(lines[i + 1]): + head = _cells(line) + aligns = [] + for c in _cells(lines[i + 1]): + aligns.append("center" if c.startswith(":") and c.endswith(":") else "right" if c.endswith(":") else "left") + i += 2 + rows = [] + while i < n and lines[i].strip() and "|" in lines[i]: + rows.append(_cells(lines[i])) + i += 1 + out.append(_table(head, aligns, rows, inl)) + continue + + if line.lstrip().startswith(">"): + quoted = [] + while i < n and lines[i].lstrip().startswith(">"): + quoted.append(re.sub(r"^\s*>\s?", "", lines[i])) + i += 1 + out.append( + '' + '
%s
' + % (accent, _blocks(quoted, inl, accent)) + ) + continue + + if _LIST.match(line): + html_list, i = _list(lines, i, inl, accent) + out.append(html_list) + continue + + para = [] + while i < n and lines[i].strip() and (not para or not _starts_block(lines, i)): + para.append(inl.render(lines[i].strip())) + i += 1 + out.append("

%s

" % (_P, "
".join(para))) + return "".join(out) + + +def _list(lines, i, inl, accent): + first = _LIST.match(lines[i]) + indent = len(first.group(1)) + ordered = first.group(2)[0].isdigit() + start = int(re.match(r"\d+", first.group(2)).group(0)) if ordered else 1 + items = [] + n = len(lines) + while i < n: + m = _LIST.match(lines[i]) + if m and len(m.group(1)) == indent and m.group(2)[0].isdigit() == ordered: + items.append([m.group(3)]) + i += 1 + continue + line = lines[i] + if not line.strip(): + # A blank line ends the list unless the list (or an item's body) goes on. + j = i + 1 + if j < n and lines[j].strip() and (len(lines[j]) - len(lines[j].lstrip())) > indent: + items[-1].append("") + i += 1 + continue + nxt = _LIST.match(lines[j]) if j < n else None + if nxt and len(nxt.group(1)) == indent: + i += 1 + continue + break + if (len(line) - len(line.lstrip())) > indent: + items[-1].append(line[indent:]) + i += 1 + continue + if m and len(m.group(1)) < indent: + break + if not m and not _starts_block(lines, i): + items[-1].append(line) # lazy continuation + i += 1 + continue + break + + li = [] + for body in items: + head, rest = body[0], body[1:] + inner = inl.render(head.strip()) + if any(r.strip() for r in rest): + dedent = min((len(r) - len(r.lstrip()) for r in rest if r.strip()), default=0) + nested = [r[dedent:] for r in rest] + if not _LIST.match(next(r for r in nested if r.strip())): + inner += "
" + inner += _blocks(nested, inl, accent).replace("margin:0 0 14px", "margin:6px 0 0") + li.append('
  • %s
  • ' % (BODY_FONT, TEXT, inner)) + tag = "ol" if ordered else "ul" + attr = ' start="%d"' % start if ordered and start != 1 else "" + return '<%s%s style="margin:0 0 14px;padding:0 0 0 24px;color:%s">%s' % (tag, attr, TEXT, "".join(li), tag), i + + +def _table(head, aligns, rows, inl): + width = len(head) + th = [] + for k, cell in enumerate(head): + radius = "border-radius:%s;" % ("9px 0 0 0" if k == 0 else "0 9px 0 0") if k in (0, width - 1) else "" + th.append( + '%s' + % (aligns[k] if k < len(aligns) else "left", PANEL, PANEL, radius, FONT, + aligns[k] if k < len(aligns) else "left", inl.render(cell)) + ) + body = [] + for r in rows: + r = (r + [""] * width)[:width] + tds = [] + for k, cell in enumerate(r): + align = aligns[k] if k < len(aligns) else "left" + tds.append( + '%s' % (align, BODY_FONT, TEXT, align, inl.render(cell)) + ) + body.append("%s" % "".join(tds)) + return ( + '' + "%s%s
    " % ("".join(th), "".join(body)) + ) + + +def markdown_text(md, lang): + """The plain-text part: the markdown as written, with pills spelled out.""" + def pill(m): + level = _level(m.group(1)) + if not level: + return m.group(0) + return "[" + (m.group(2) or m.group(1).upper()) + "]" + return re.sub(r"\{\{\s*([^}:]+?)\s*(?::\s*([^}]*?)\s*)?\}\}", pill, md.strip()) + + +def _plain(md): + """A one-line excerpt for the hidden preheader.""" + t = re.sub(r"```.*?```", " ", md, flags=re.S) + t = re.sub(r"\{\{\s*[^}:]+?\s*(?::\s*([^}]*?)\s*)?\}\}", lambda m: m.group(1) or "", t) + t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t) + t = re.sub(r"^\s*(#{1,6}|>|[-*+]|\d+[.)])\s+", "", t, flags=re.M) + t = re.sub(r"[*_`~|]", "", t) + t = re.sub(r"^\s*:?-{2,}.*$", "", t, flags=re.M) + return re.sub(r"\s+", " ", t).strip()[:140] + + +# --- Mail ------------------------------------------------------------------- + +def _when(raw, lang): + if raw: + try: + d = datetime.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + raise Exception("date must be ISO 8601 (e.g. 2026-07-16T12:54:00Z), got " + json.dumps(raw)) + if d.tzinfo is None: + d = d.replace(tzinfo=datetime.timezone.utc) + d = d.astimezone(datetime.timezone.utc) + else: + d = datetime.datetime.now(datetime.timezone.utc) + if lang == "fr": + return d.strftime("%d/%m/%Y à %H:%M UTC") + return "%d %s %d, %s UTC" % (d.day, STRINGS["en"]["months"][d.month - 1], d.year, d.strftime("%H:%M")) + + +def _choice(inputs, key, allowed, default): + v = _s(inputs.get(key)).lower() or default + if v not in allowed: + raise Exception("%s must be one of %s, got %s" % (key, ", ".join(allowed), json.dumps(v))) + return v + + +def render(cfg, inputs): + template = _choice(inputs, "template", list(TEMPLATES), "info") + lang = _choice(inputs, "lang", ["fr", "en"], "fr") + audience = _choice(inputs, "audience", ["soc", "client"], "soc") + subject = _s(inputs.get("subject")) + description = str(inputs.get("description") or "").strip() + if not subject: + raise Exception("subject is required") + if not description: + raise Exception("description is required") + + severity = None + if _s(inputs.get("severity")): + severity = _level(inputs.get("severity")) + if not severity or severity == "ok": + raise Exception("severity must be one of critical, high, medium, low, info, got " + json.dumps(_s(inputs.get("severity")))) + case_ref = _s(inputs.get("case_ref")) + button_label = _s(inputs.get("button_label")) + button_url = _s(inputs.get("button_url")) + if button_url and not re.match(r"^https?://", button_url, re.I): + raise Exception("button_url must start with http:// or https://") + if button_label and not button_url: + raise Exception("button_url is required with button_label") + callout_text = str(inputs.get("callout_text") or "").strip() + bot = _s(cfg.get("bot_name")) or "Riposte" + + t = TEMPLATES[template] + badge = t[lang] + accent = t["color"] + if template == "incident_notification": + accent = LEVELS[severity] if severity else LEVELS["info"] + if severity: + badge = "%s %s" % (badge, LEVEL_LABELS[lang][severity]) if lang == "fr" else "%s %s" % (LEVEL_LABELS[lang][severity], badge) + badge_full = (t["icon"] + " " + badge).strip() + callout_title = _s(inputs.get("callout_title")) or badge_full + + inl = Inline(lang) + esc = html.escape + when = _when(_s(inputs.get("date")), lang) + + meta = [esc(when)] + if case_ref: + meta.append('%s' % (MONO, esc(case_ref))) + if severity and template != "incident_notification": + meta.append(_pill(severity, LEVEL_LABELS[lang][severity])) + meta_html = ' · ' % MUTED + meta_html = meta_html.join(meta) + + callout = "" + if callout_text: + callout = ( + '' + '
    ' + '
    %s
    ' + "%s
    " + % (_mix(accent, "#565656", 0.8), _mix(accent, "#565656", 0.8), accent, FONT, _vivid(accent), + esc(callout_title), markdown(callout_text, lang, accent).replace("margin:0 0 14px", "margin:0 0 6px")) + ) + + button = "" + if button_url: + button = ( + '' + '
    ' + '%s
    ' + % (BLUE_RULE, BLUE_RULE, esc(button_url, quote=True), FONT, esc(button_label or button_url)) + ) + + if audience == "client": + footer = esc(STRINGS[lang]["client_footer"]) + "
    " + esc(STRINGS[lang]["tagline"]) + footer_text = STRINGS[lang]["client_footer"] + "\n" + STRINGS[lang]["tagline"] + else: + footer = STRINGS[lang]["soc_footer"].format(blue=BLUE, bot=esc(bot)) + footer_text = STRINGS[lang]["soc_footer_text"].format(bot=bot) + + logo = "" + if LOGO_PNG_B64 and not LOGO_PNG_B64.startswith("__"): + logo = ('ACKnowledge' % LOGO_PNG_B64) + + preheader = esc(_plain(callout_text + " " + description) if callout_text else _plain(description)) + + page = """ + + +{title} + + +
    {preheader}
    +
    + + + + + + + + + +
    + +""".format( + lang=lang, title=esc(subject), outer=OUTER, page=PAGE, header=HEADER, header_rule=HEADER_RULE, + font=FONT, body_font=BODY_FONT, blue=BLUE, blue_rule=BLUE_RULE, signature=SIGNATURE, muted=MUTED, + accent=accent, badge=esc(badge_full), meta=meta_html, preheader=preheader, + callout=callout, description=markdown(description, lang, accent), button=button, + logo=logo, footer=footer, + ) + + head = [("[%s] " % badge) + subject] + head.append(" · ".join([when] + ([case_ref] if case_ref else []) + ([LEVEL_LABELS[lang][severity]] if severity and template != "incident_notification" else []))) + text = ["\n".join(head)] + if callout_text: + text.append(callout_title + "\n" + markdown_text(callout_text, lang)) + text.append(markdown_text(description, lang)) + if button_url: + text.append((button_label + ": " if button_label else "") + button_url) + text.append("--\nSOC | Security Operation Center\nACKnowledge\nw: www.acknowledge.fr\ne: soc@acknowledge.fr") + text.append(footer_text) + + full_subject = subject + if case_ref and case_ref not in subject: + full_subject = "[%s] %s" % (case_ref, subject) + + return { + "subject": full_subject, + "html_body": page, + "text_body": "\n\n".join(text) + "\n", + "template": template, + "lang": lang, + "audience": audience, + "html_bytes": len(page.encode("utf-8")), + } + + +def main(): + try: + print(json.dumps(render(_cfg(), _inputs()), ensure_ascii=False)) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/integrations/mail-templates/scripts/test_connection.py b/integrations/mail-templates/scripts/test_connection.py new file mode 100644 index 0000000..8201aa8 --- /dev/null +++ b/integrations/mail-templates/scripts/test_connection.py @@ -0,0 +1,27 @@ +import json, os, sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +SAMPLE = "## Sample\n\n**1 ticket** waiting.\n\n| Ticket | Priority |\n|---|---|\n| SOC-1 | {{medium}} |\n\n```\n8.8.8.8\n```" + + +def main(): + try: + import render_mail_template as r + except ImportError: + # The runner ships one script per command: say so instead of failing the Test button. + print(json.dumps({"ok": True, "detail": "No connection to test; render a template to check the output."})) + return + try: + cfg = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + sizes = {} + for name in r.TEMPLATES: + out = r.render(cfg, {"template": name, "subject": "Test", "description": SAMPLE, "severity": "high"}) + sizes[name] = out["html_bytes"] + print(json.dumps({"ok": True, "templates": sizes})) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +main()