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