da3909e79d
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>
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
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)
|