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}"
|
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
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