diff --git a/email_template.txt b/email_template.txt new file mode 100644 index 0000000..d83a236 --- /dev/null +++ b/email_template.txt @@ -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 diff --git a/openvpncertupdate.py b/openvpncertupdate.py index d3c608e..e6de6bc 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -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) # ============================================================ diff --git a/tests/test_mailer.py b/tests/test_mailer.py new file mode 100644 index 0000000..5733a7c --- /dev/null +++ b/tests/test_mailer.py @@ -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")