feat(mail-sender): new SMTP mail sender integration

Send email over SMTP: plain-text and HTML bodies with inline data-URI
images, base64 file attachment, CC/BCC, Reply-To and custom headers.
Plain / STARTTLS / SSL-TLS with optional authentication. Stdlib-only
(smtplib), no extra Python dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Guillaume BOURGEOIS
2026-07-10 23:48:23 +02:00
parent 84be770928
commit da3909e79d
3 changed files with 296 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
id: mail_sender
name: Mail Sender
version: 1.0.0
description: "Send email over SMTP — plain-text and rich HTML bodies (with inline data-URI images), file attachments (base64), CC/BCC, Reply-To and custom headers. Supports plain, STARTTLS and SSL/TLS connections with optional authentication. Stdlib-only (smtplib), no extra Python dependencies; run on a remote engine when the SMTP server is only reachable from inside your network."
changelog: "1.0.0 — Initial release: send-mail (text/HTML, inline images, base64 attachment, CC/BCC/Reply-To/custom headers) and a connectivity test."
category: email
# Per-instance configuration. Connects to an SMTP server to send outbound mail.
# tls: None (plain, usually port 25), STARTTLS (upgrade a plain connection,
# usually port 587), or SSL/TLS (implicit TLS, usually port 465). Username and
# password are optional (some relays accept unauthenticated internal senders).
config_schema:
properties:
host:
type: string
description: "SMTP server hostname or IP"
port:
type: string
description: "SMTP port (25 plain, 587 STARTTLS, 465 SSL/TLS)"
default: "25"
username:
type: string
description: "SMTP username (leave empty for unauthenticated relays)"
password:
type: string
description: "SMTP password"
x-soar-sensitive: true
from_address:
type: string
description: "Default sender address — 'user@host.com' or 'Full Name <user@host.com>'"
fqdn:
type: string
description: "Fully qualified domain name to present in EHLO (optional)"
tls:
type: string
description: "Connection security: None, STARTTLS or SSL/TLS"
default: None
insecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
required:
- host
- port
- from_address
commands:
- id: send_mail
name: mail-sender-send-mail
description: "Send an email. At least one recipient (to/cc/bcc) is required; subject and body may be empty."
inputs_schema:
properties:
to: { type: string, description: "Recipient(s) for the To field, comma-separated" }
cc: { type: string, description: "CC recipient(s), comma-separated" }
bcc: { type: string, description: "BCC recipient(s), comma-separated" }
subject: { type: string, description: "Subject" }
body: { type: string, description: "Plain-text body" }
html_body: { type: string, description: "HTML body (inline data-URI images are extracted and embedded)" }
reply_to: { type: string, description: "Reply-To address" }
sender: { type: string, description: "Override the configured From address" }
attachment_name: { type: string, description: "Attachment file name (with attachment_base64)" }
attachment_base64: { type: string, description: "Attachment content, base64-encoded" }
additional_headers: { type: string, description: "Extra headers as name=value;name2=value2" }
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: mail-sender-test-connection
description: "Verify the SMTP connection and credentials without sending mail (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,168 @@
import base64, json, os, random, re, smtplib, ssl, string, sys
from email import encoders
from email.header import Header
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
UTF_8 = "utf-8"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _randomword(n):
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))
def _header(s):
if not s:
return None
return Header(" ".join(str(s).splitlines()))
def connect(cfg):
host = str(cfg.get("host") or "")
port = int(cfg.get("port") or 25)
fqdn = (str(cfg.get("fqdn") or "").strip()) or None
tls = str(cfg.get("tls") or "None")
ctx = None
if cfg.get("insecure"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
if tls == "SSL/TLS":
server = smtplib.SMTP_SSL(host, port, local_hostname=fqdn, context=ctx, timeout=60)
else:
server = smtplib.SMTP(host, port, local_hostname=fqdn, timeout=60)
server.ehlo()
if tls == "STARTTLS" or str(tls).lower() == "true":
server.starttls(context=ctx)
server.ehlo()
user = str(cfg.get("username") or "")
if user:
server.login(user, str(cfg.get("password") or ""))
return server
def handle_html(html_body):
"""Extract inline data-URI images from the HTML and return (clean_body, image attachments)."""
attachments = []
clean = ""
last = 0
for i, m in enumerate(re.finditer(r'<img.+?src="(data:(image/.+?);base64,([a-zA-Z0-9+/=\r\n]+?))"', html_body, re.I)):
_, subtype = m.group(2).split("/", 1)
name = "image" + str(i) + "." + subtype
cid = name + "@" + _randomword(8) + "." + _randomword(8)
attachments.append({"subtype": subtype, "data": base64.b64decode(m.group(3)), "cid": cid})
clean += html_body[last:m.start(1)] + "cid:" + cid
last = m.end() - 1
clean += html_body[last:]
return clean, attachments
def split_fields(s):
out = []
for part in str(s or "").split(";"):
if "=" in part:
k, v = part.split("=", 1)
if k.strip():
out.append((k.strip(), v))
return out
def build_message(cfg, inputs):
to = [x.strip() for x in str(inputs.get("to") or "").split(",") if x.strip()]
cc = [x.strip() for x in str(inputs.get("cc") or "").split(",") if x.strip()]
bcc = [x.strip() for x in str(inputs.get("bcc") or "").split(",") if x.strip()]
if not to and not cc and not bcc:
raise Exception("at least one recipient (to, cc or bcc) is required")
subject = str(inputs.get("subject") or "")
body = str(inputs.get("body") or "")
html_body = str(inputs.get("html_body") or "")
att_name = inputs.get("attachment_name")
att_b64 = inputs.get("attachment_base64")
has_att = bool(att_name and att_b64)
inline = []
if html_body:
html_body, inline = handle_html(html_body)
if not html_body:
if has_att:
msg = MIMEMultipart()
msg.attach(MIMEText(body, "plain", UTF_8))
else:
msg = MIMEText(body, "plain", UTF_8)
else:
if has_att or inline:
msg = MIMEMultipart()
if body:
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(body, "plain", UTF_8))
alt.attach(MIMEText(html_body, "html", UTF_8))
msg.attach(alt)
else:
msg.attach(MIMEText(html_body, "html", UTF_8))
elif body:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "plain", UTF_8))
msg.attach(MIMEText(html_body, "html", UTF_8))
else:
msg = MIMEText(html_body, "html", UTF_8)
for img in inline:
part = MIMEImage(img["data"], img["subtype"])
part.add_header("Content-Disposition", "inline", filename=img["cid"].split("@")[0])
part.add_header("Content-ID", "<" + img["cid"] + ">")
msg.attach(part)
if has_att:
part = MIMEBase("application", "octet-stream")
part.set_payload(base64.b64decode(att_b64))
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=str(att_name))
msg.attach(part)
sender = str(inputs.get("sender") or cfg.get("from_address") or "")
msg["Subject"] = _header(subject)
msg["From"] = _header(sender)
if inputs.get("reply_to"):
msg["Reply-To"] = _header(inputs["reply_to"])
if to:
msg["To"] = _header(",".join(to))
if cc:
msg["CC"] = _header(",".join(cc))
for name, value in split_fields(inputs.get("additional_headers")):
msg[name] = _header(value)
return sender, to + cc + bcc, msg.as_string()
def main():
cfg = _cfg()
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
sender, recipients, raw = build_message(cfg, inputs)
if not sender:
raise Exception("no sender address (set from_address in the config or pass sender)")
server = connect(cfg)
try:
server.sendmail(sender, recipients, raw)
finally:
try:
server.quit()
except Exception:
pass
print(json.dumps({"ok": True, "sent_to": recipients}))
try:
main()
except smtplib.SMTPException as e:
print(json.dumps({"error": "SMTP error: " + str(e)}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,54 @@
import json, os, smtplib, ssl, sys
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def connect(cfg):
host = str(cfg.get("host") or "")
port = int(cfg.get("port") or 25)
fqdn = (str(cfg.get("fqdn") or "").strip()) or None
tls = str(cfg.get("tls") or "None")
ctx = None
if cfg.get("insecure"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
if tls == "SSL/TLS":
server = smtplib.SMTP_SSL(host, port, local_hostname=fqdn, context=ctx, timeout=60)
else:
server = smtplib.SMTP(host, port, local_hostname=fqdn, timeout=60)
server.ehlo()
if tls == "STARTTLS" or str(tls).lower() == "true":
server.starttls(context=ctx)
server.ehlo()
user = str(cfg.get("username") or "")
if user:
server.login(user, str(cfg.get("password") or ""))
return server
def main():
cfg = _cfg()
if not cfg.get("host"):
raise Exception("host is not set")
server = connect(cfg)
try:
server.noop()
finally:
try:
server.quit()
except Exception:
pass
print(json.dumps({"ok": True, "host": cfg.get("host"), "tls": cfg.get("tls") or "None"}))
try:
main()
except smtplib.SMTPException as e:
print(json.dumps({"error": "SMTP error: " + str(e)}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)