diff --git a/integrations/mail-sender/manifest.yaml b/integrations/mail-sender/manifest.yaml new file mode 100644 index 0000000..652fb12 --- /dev/null +++ b/integrations/mail-sender/manifest.yaml @@ -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 '" + 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: {} } diff --git a/integrations/mail-sender/scripts/send_mail.py b/integrations/mail-sender/scripts/send_mail.py new file mode 100644 index 0000000..60020e2 --- /dev/null +++ b/integrations/mail-sender/scripts/send_mail.py @@ -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'") + 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) diff --git a/integrations/mail-sender/scripts/test_connection.py b/integrations/mail-sender/scripts/test_connection.py new file mode 100644 index 0000000..da63322 --- /dev/null +++ b/integrations/mail-sender/scripts/test_connection.py @@ -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)