diff --git a/openvpncertupdate.py b/openvpncertupdate.py index 79654e2..366a8b3 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -335,7 +335,7 @@ def create_note(content: str, base_url: str) -> str: # ============================================================ -def send_email( +def build_mime_message( to_address: str, cn: str, one_time_url: str, @@ -343,9 +343,8 @@ def send_email( mail_from: str, subject: str, template_path: str, - mail_binary: str, -) -> None: - """Compose and send email with .ovpn attachment via msmtp/sendmail.""" +) -> email.mime.multipart.MIMEMultipart: + """Build the MIME message with .ovpn attachment; does not send.""" config_name = os.path.basename(ovpn_path) body = Path(template_path).read_text().format( cn=cn, url=one_time_url, config_name=config_name, @@ -362,7 +361,22 @@ def send_email( email.encoders.encode_base64(part) part.add_header("Content-Disposition", f'attachment; filename="{config_name}"') msg.attach(part) + return msg + +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.""" + msg = build_mime_message(to_address, cn, one_time_url, ovpn_path, + mail_from, subject, template_path) proc = subprocess.Popen( [mail_binary, "-t"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -984,13 +998,17 @@ class CursesApp: class CliRunner: """Non-interactive runner for scripting / cron use.""" - def create(self, cn: str, email_addr: str, send_email_flag: bool = True) -> None: - self._issue(cn, email_addr, is_renewal=False, send_email_flag=send_email_flag) + def create(self, cn: str, email_addr: str, send_email_flag: bool = True, + show_eml: bool = False) -> None: + self._issue(cn, email_addr, is_renewal=False, + send_email_flag=send_email_flag, show_eml=show_eml) - def reissue(self, cn: str, email_addr: str, send_email_flag: bool = True) -> None: + def reissue(self, cn: str, email_addr: str, send_email_flag: bool = True, + show_eml: bool = False) -> None: # Prefer the caller-supplied email; fall back to stored metadata. final_email = email_addr or get_email(EASYRSA_PKI_DIR, cn) - self._issue(cn, final_email, is_renewal=True, send_email_flag=send_email_flag) + self._issue(cn, final_email, is_renewal=True, + send_email_flag=send_email_flag, show_eml=show_eml) def revoke(self, cn: str) -> None: print(f"Revoking {cn}…", file=sys.stderr) @@ -1014,7 +1032,7 @@ class CliRunner: print(f"CRL updated → {CRL_DEST_PATH}") def _issue(self, cn: str, email_addr: str, is_renewal: bool, - send_email_flag: bool = True) -> None: + send_email_flag: bool = True, show_eml: bool = False) -> None: password = generate_password() if is_renewal: @@ -1056,6 +1074,15 @@ class CliRunner: except CryptgeonError as exc: print(f"warning: Cryptgeon failed: {exc}", file=sys.stderr) + if show_eml and email_addr: + try: + msg = build_mime_message(email_addr, cn, one_time_url or password, + ovpn_path, MAIL_FROM, MAIL_SUBJECT, + EMAIL_TEMPLATE_PATH) + print(base64.b64encode(msg.as_bytes()).decode()) + except Exception as exc: + print(f"warning: could not build .eml: {exc}", file=sys.stderr) + if email_addr and send_email_flag: try: send_email(email_addr, cn, one_time_url or password, ovpn_path, @@ -1084,9 +1111,12 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--email", metavar="EMAIL", help="Email address (required for --create; optional for --reissue)") parser.add_argument("--send-email", dest="send_email", action="store_true", - default=True, help="Send email after issuing cert (default)") + default=None, help="Send email after issuing cert (default unless --show-eml)") parser.add_argument("--no-send-email", dest="send_email", action="store_false", help="Skip email delivery; print URL to stdout instead") + parser.add_argument("--show-eml", action="store_true", + help="Print the generated email as base64-encoded .eml to stdout " + "(implies --no-send-email unless --send-email is also given)") return parser @@ -1099,12 +1129,17 @@ def main() -> None: parser = _build_parser() args = parser.parse_args() + # --show-eml switches the default to --no-send-email; explicit --send-email overrides. + send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml) + if args.create: if not args.email: parser.error("--email is required with --create") - CliRunner().create(args.create, args.email, send_email_flag=args.send_email) + CliRunner().create(args.create, args.email, + send_email_flag=send_email_flag, show_eml=args.show_eml) elif args.reissue: - CliRunner().reissue(args.reissue, args.email or "", send_email_flag=args.send_email) + CliRunner().reissue(args.reissue, args.email or "", + send_email_flag=send_email_flag, show_eml=args.show_eml) elif args.revoke: CliRunner().revoke(args.revoke) elif args.gen_crl: diff --git a/tests/test_cli.py b/tests/test_cli.py index 3364d64..ed4755e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -165,7 +165,7 @@ def test_main_dispatches_create(monkeypatch): runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() - runner.create.assert_called_once_with("alice", "a@b.com", send_email_flag=True) + runner.create.assert_called_once_with("alice", "a@b.com", send_email_flag=True, show_eml=False) def test_main_dispatches_reissue(monkeypatch): @@ -173,7 +173,7 @@ def test_main_dispatches_reissue(monkeypatch): runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() - runner.reissue.assert_called_once_with("bob", "", send_email_flag=True) + runner.reissue.assert_called_once_with("bob", "", send_email_flag=True, show_eml=False) def test_main_dispatches_revoke(monkeypatch): @@ -219,4 +219,82 @@ def test_main_no_send_email_flag(monkeypatch): runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() - runner.reissue.assert_called_once_with("bob", "", send_email_flag=False) + runner.reissue.assert_called_once_with("bob", "", send_email_flag=False, show_eml=False) + + +# --------------------------------------------------------------------------- +# --show-eml +# --------------------------------------------------------------------------- + +def test_show_eml_outputs_base64(monkeypatch, capsys, tmp_path): + """--show-eml prints a non-empty base64 string to stdout.""" + import base64 as _b64 + # Write a minimal template and .ovpn so build_mime_message can read them + template = tmp_path / "tpl.txt" + template.write_text("Hi {cn}, url={url}, cfg={config_name}") + ovpn = tmp_path / "c.ovpn" + ovpn.write_bytes(b"ovpn-data") + + mocks = _patch_issue(monkeypatch, ovpn_path=str(ovpn)) + import openvpncertupdate as m + monkeypatch.setattr(m, "EMAIL_TEMPLATE_PATH", str(template)) + monkeypatch.setattr(m, "build_mime_message", + m.build_mime_message) # keep real implementation + + CliRunner().create("alice", "alice@example.com", + send_email_flag=False, show_eml=True) + out = capsys.readouterr().out + # Find the base64 block (before the "config:" line) + b64_line = [l for l in out.splitlines() if l and not l.startswith(("config:", "password"))][0] + decoded = _b64.b64decode(b64_line) + assert b"alice@example.com" in decoded + assert b"alice" in decoded + + +def test_show_eml_implies_no_send(monkeypatch, capsys): + """--show-eml without --send-email must not call send_email.""" + mocks = _patch_issue(monkeypatch) + import openvpncertupdate as m + monkeypatch.setattr(m, "build_mime_message", MagicMock( + return_value=MagicMock(as_bytes=MagicMock(return_value=b"eml")) + )) + CliRunner().create("alice", "alice@example.com", + send_email_flag=False, show_eml=True) + mocks["send_email"].assert_not_called() + + +def test_show_eml_with_send_email_still_sends(monkeypatch, capsys): + """--show-eml --send-email: both show eml and send.""" + mocks = _patch_issue(monkeypatch) + import openvpncertupdate as m + monkeypatch.setattr(m, "build_mime_message", MagicMock( + return_value=MagicMock(as_bytes=MagicMock(return_value=b"eml")) + )) + CliRunner().create("alice", "alice@example.com", + send_email_flag=True, show_eml=True) + mocks["send_email"].assert_called_once() + + +def test_main_show_eml_defaults_to_no_send(monkeypatch): + """--show-eml with no explicit --send-email → send_email_flag=False.""" + monkeypatch.setattr(sys, "argv", + ["prog", "--create", "alice", "--email", "a@b.com", "--show-eml"]) + runner = MagicMock() + monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) + main() + runner.create.assert_called_once_with( + "alice", "a@b.com", send_email_flag=False, show_eml=True + ) + + +def test_main_show_eml_send_email_override(monkeypatch): + """--show-eml --send-email → send_email_flag=True.""" + monkeypatch.setattr(sys, "argv", + ["prog", "--create", "alice", "--email", "a@b.com", + "--show-eml", "--send-email"]) + runner = MagicMock() + monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) + main() + runner.create.assert_called_once_with( + "alice", "a@b.com", send_email_flag=True, show_eml=True + )