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:
11
email_template.txt
Normal file
11
email_template.txt
Normal 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
|
||||
@@ -309,6 +309,50 @@ def create_note(content: str, base_url: str) -> str:
|
||||
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)
|
||||
# ============================================================
|
||||
|
||||
|
||||
47
tests/test_mailer.py
Normal file
47
tests/test_mailer.py
Normal 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")
|
||||
Reference in New Issue
Block a user