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) main()