feat: --show-eml outputs base64-encoded .eml; implies --no-send-email by default

This commit is contained in:
Vlad Doloman
2026-06-24 03:02:38 +03:00
parent 996000c5ae
commit 006cedebbe
2 changed files with 128 additions and 15 deletions

View File

@@ -335,7 +335,7 @@ def create_note(content: str, base_url: str) -> str:
# ============================================================ # ============================================================
def send_email( def build_mime_message(
to_address: str, to_address: str,
cn: str, cn: str,
one_time_url: str, one_time_url: str,
@@ -343,9 +343,8 @@ def send_email(
mail_from: str, mail_from: str,
subject: str, subject: str,
template_path: str, template_path: str,
mail_binary: str, ) -> email.mime.multipart.MIMEMultipart:
) -> None: """Build the MIME message with .ovpn attachment; does not send."""
"""Compose and send email with .ovpn attachment via msmtp/sendmail."""
config_name = os.path.basename(ovpn_path) config_name = os.path.basename(ovpn_path)
body = Path(template_path).read_text().format( body = Path(template_path).read_text().format(
cn=cn, url=one_time_url, config_name=config_name, cn=cn, url=one_time_url, config_name=config_name,
@@ -362,7 +361,22 @@ def send_email(
email.encoders.encode_base64(part) email.encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="{config_name}"') part.add_header("Content-Disposition", f'attachment; filename="{config_name}"')
msg.attach(part) 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( proc = subprocess.Popen(
[mail_binary, "-t"], [mail_binary, "-t"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
@@ -984,13 +998,17 @@ class CursesApp:
class CliRunner: class CliRunner:
"""Non-interactive runner for scripting / cron use.""" """Non-interactive runner for scripting / cron use."""
def create(self, cn: str, email_addr: str, send_email_flag: bool = True) -> None: def create(self, cn: str, email_addr: str, send_email_flag: bool = True,
self._issue(cn, email_addr, is_renewal=False, send_email_flag=send_email_flag) 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. # Prefer the caller-supplied email; fall back to stored metadata.
final_email = email_addr or get_email(EASYRSA_PKI_DIR, cn) 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: def revoke(self, cn: str) -> None:
print(f"Revoking {cn}", file=sys.stderr) print(f"Revoking {cn}", file=sys.stderr)
@@ -1014,7 +1032,7 @@ class CliRunner:
print(f"CRL updated → {CRL_DEST_PATH}") print(f"CRL updated → {CRL_DEST_PATH}")
def _issue(self, cn: str, email_addr: str, is_renewal: bool, 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() password = generate_password()
if is_renewal: if is_renewal:
@@ -1056,6 +1074,15 @@ class CliRunner:
except CryptgeonError as exc: except CryptgeonError as exc:
print(f"warning: Cryptgeon failed: {exc}", file=sys.stderr) 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: if email_addr and send_email_flag:
try: try:
send_email(email_addr, cn, one_time_url or password, ovpn_path, 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", parser.add_argument("--email", metavar="EMAIL",
help="Email address (required for --create; optional for --reissue)") help="Email address (required for --create; optional for --reissue)")
parser.add_argument("--send-email", dest="send_email", action="store_true", 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", parser.add_argument("--no-send-email", dest="send_email", action="store_false",
help="Skip email delivery; print URL to stdout instead") 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 return parser
@@ -1099,12 +1129,17 @@ def main() -> None:
parser = _build_parser() parser = _build_parser()
args = parser.parse_args() 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 args.create:
if not args.email: if not args.email:
parser.error("--email is required with --create") 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: 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: elif args.revoke:
CliRunner().revoke(args.revoke) CliRunner().revoke(args.revoke)
elif args.gen_crl: elif args.gen_crl:

View File

@@ -165,7 +165,7 @@ def test_main_dispatches_create(monkeypatch):
runner = MagicMock() runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main() 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): def test_main_dispatches_reissue(monkeypatch):
@@ -173,7 +173,7 @@ def test_main_dispatches_reissue(monkeypatch):
runner = MagicMock() runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main() 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): def test_main_dispatches_revoke(monkeypatch):
@@ -219,4 +219,82 @@ def test_main_no_send_email_flag(monkeypatch):
runner = MagicMock() runner = MagicMock()
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
main() 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
)