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:
@@ -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)
|
||||||
Reference in New Issue
Block a user