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
@@ -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)