feat: MIME email composer with .ovpn attachment via msmtp/sendmail

Add send_email() function that composes multipart MIME messages with
template-based body and .ovpn file attachment, piped to mail binary via
subprocess.Popen. Supports Cryptgeon URL insertion and sender metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-06-23 23:58:00 +03:00
parent 5b9ee8d448
commit a2593b644e
3 changed files with 102 additions and 0 deletions

11
email_template.txt Normal file
View File

@@ -0,0 +1,11 @@
Hello,
Your OpenVPN configuration has been updated for: {cn}
Retrieve your one-time password here (link expires after one view):
{url}
Your configuration file is attached as '{config_name}'.
Regards,
VPN Admin

View File

@@ -309,6 +309,50 @@ def create_note(content: str, base_url: str) -> str:
return f"{base_url.rstrip('/')}/#/note/{note_id}/{key_fragment}" return f"{base_url.rstrip('/')}/#/note/{note_id}/{key_fragment}"
# ============================================================
# === MAILER ===
# ============================================================
def send_email(
to_address: str,
cn: str,
one_time_url: str,
ovpn_path: str,
mail_from: str,
subject: str,
template_path: str,
mail_binary: str,
) -> None:
"""Compose and send email with .ovpn attachment via msmtp/sendmail."""
config_name = os.path.basename(ovpn_path)
body = Path(template_path).read_text().format(
cn=cn, url=one_time_url, config_name=config_name,
)
msg = email.mime.multipart.MIMEMultipart()
msg["From"] = mail_from
msg["To"] = to_address
msg["Subject"] = subject
msg.attach(email.mime.text.MIMEText(body, "plain"))
with open(ovpn_path, "rb") as fh:
part = email.mime.base.MIMEBase("application", "octet-stream")
part.set_payload(fh.read())
email.encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="{config_name}"')
msg.attach(part)
proc = subprocess.Popen(
[mail_binary, "-t"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
_, err = proc.communicate(input=msg.as_bytes())
if proc.returncode != 0:
raise RuntimeError(
f"mail delivery failed (exit {proc.returncode}): {err.decode().strip()}"
)
# (remaining sections added in later tasks) # (remaining sections added in later tasks)
# ============================================================ # ============================================================

47
tests/test_mailer.py Normal file
View File

@@ -0,0 +1,47 @@
import pytest
from unittest.mock import patch, MagicMock
from openvpncertupdate import send_email
def make_popen(returncode=0):
proc = MagicMock()
proc.communicate.return_value = (b"", b"")
proc.returncode = returncode
return proc
def test_calls_mail_binary(tmp_path):
tmpl = tmp_path / "t.txt"; tmpl.write_text("{cn} {url} {config_name}")
att = tmp_path / "c.ovpn"; att.write_text("x")
captured = {}
def fake_popen(cmd, **kw): captured["cmd"] = cmd; return make_popen()
with patch("openvpncertupdate.subprocess.Popen", side_effect=fake_popen):
send_email("to@x.com", "alice", "http://u", str(att),
"f@x.com", "Subj", str(tmpl), "msmtp")
assert captured["cmd"] == ["msmtp", "-t"]
def test_body_contains_cn_and_url(tmp_path):
tmpl = tmp_path / "t.txt"; tmpl.write_text("Dear {cn}, see {url} for {config_name}")
att = tmp_path / "c.ovpn"; att.write_text("")
received = {}
def fake_popen(cmd, **kw):
p = MagicMock()
p.communicate = lambda input=None: (received.update({"data": input}) or b"", b"")
p.returncode = 0
return p
with patch("openvpncertupdate.subprocess.Popen", side_effect=fake_popen):
send_email("to@x.com", "bob", "http://secret", str(att),
"f@x.com", "s", str(tmpl), "msmtp")
body = received["data"].decode()
assert "bob" in body
assert "http://secret" in body
def test_raises_on_failure(tmp_path):
tmpl = tmp_path / "t.txt"; tmpl.write_text("{cn} {url} {config_name}")
att = tmp_path / "c.ovpn"; att.write_text("")
with patch("openvpncertupdate.subprocess.Popen", return_value=make_popen(returncode=1)):
with pytest.raises(RuntimeError, match="mail"):
send_email("x@y.com", "cn", "url", str(att),
"f@x.com", "s", str(tmpl), "msmtp")