feat: SMTP mail stack and separate server.env deploy
Add mail.php with STARTTLS sending, test scripts, server.env split from local .env, admin@ postbox docs, DKIM DNS support-ticket notes, and todo 11 for boka relay. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+28
-3
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deploy public/, config/, and .env to wint.global / Plesk via FTP/FTPS."""
|
||||
"""Deploy public/, config/, and server.env to wint.global / Plesk via FTP/FTPS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -85,6 +85,21 @@ def upload_file(ftp: ftplib.FTP, local: Path, remote_path: str) -> None:
|
||||
print(f" ↑ {local.relative_to(ROOT)} → {remote_path}")
|
||||
|
||||
|
||||
def delete_remote_file(ftp: ftplib.FTP, remote_path: str) -> None:
|
||||
remote_path = remote_path.replace("\\", "/").lstrip("/")
|
||||
remote_dir = os.path.dirname(remote_path)
|
||||
remote_name = os.path.basename(remote_path)
|
||||
try:
|
||||
if remote_dir:
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
else:
|
||||
cwd_to_root(ftp)
|
||||
ftp.delete(remote_name)
|
||||
print(f" ✕ deleted {remote_path}")
|
||||
except ftplib.error_perm as exc:
|
||||
print(f" (skip delete {remote_path}: {exc})")
|
||||
|
||||
|
||||
def upload_tree(ftp: ftplib.FTP, local_dir: Path, remote_base: str) -> None:
|
||||
remote_base = remote_base.strip("/")
|
||||
for path in sorted(local_dir.rglob("*")):
|
||||
@@ -101,6 +116,13 @@ def main() -> None:
|
||||
env = load_env(ROOT / ".env")
|
||||
require_env(env, "FTP_HOST", "FTP_USER", "FTP_PASSWORD")
|
||||
|
||||
server_env = ROOT / "server.env"
|
||||
if not server_env.is_file():
|
||||
sys.exit(
|
||||
"Missing server.env — run: python3 scripts/sync-server-env.py\n"
|
||||
"(or copy server.env.example to server.env and fill in server values)"
|
||||
)
|
||||
|
||||
remote_web = env.get("FTP_REMOTE_PATH", "httpdocs").strip("/")
|
||||
remote_private = env.get("FTP_PRIVATE_PATH", "private").strip("/")
|
||||
|
||||
@@ -109,8 +131,10 @@ def main() -> None:
|
||||
config_files = sum(1 for p in (ROOT / "config").rglob("*") if p.is_file())
|
||||
print(f" public/ ({public_files} files) → {remote_web}/")
|
||||
print(f" config/ ({config_files} files) → {remote_private}/config/")
|
||||
print(f" .env → {remote_private}/.env")
|
||||
print(f" server.env → {remote_private}/server.env")
|
||||
print(f" (remove legacy {remote_private}/.env if present)")
|
||||
print(f" (mkdir) {remote_private}/data/")
|
||||
print(" .env stays local — not deployed")
|
||||
|
||||
if args.dry_run:
|
||||
return
|
||||
@@ -122,7 +146,8 @@ def main() -> None:
|
||||
upload_tree(ftp, ROOT / "public", remote_web)
|
||||
upload_tree(ftp, ROOT / "config", f"{remote_private}/config")
|
||||
ensure_remote_dir(ftp, f"{remote_private}/data")
|
||||
upload_file(ftp, ROOT / ".env", f"{remote_private}/.env")
|
||||
upload_file(ftp, server_env, f"{remote_private}/server.env")
|
||||
delete_remote_file(ftp, f"{remote_private}/.env")
|
||||
print("\nDone.")
|
||||
finally:
|
||||
ftp.quit()
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy server-relevant keys from local .env to server.env (never deploys .env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
SERVER_KEYS = [
|
||||
"DOMAIN",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_ENCRYPTION",
|
||||
"SMTP_USER",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_FROM",
|
||||
"ALERT_EMAIL",
|
||||
"MONITOR_INGEST_TOKEN",
|
||||
"MONITOR_INGEST_URL",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"TELEGRAM_CHAT_ID",
|
||||
]
|
||||
|
||||
|
||||
def load_env(path: Path) -> dict[str, str]:
|
||||
env: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
env[key.strip()] = value.strip()
|
||||
return env
|
||||
|
||||
|
||||
def main() -> None:
|
||||
local = ROOT / ".env"
|
||||
target = ROOT / "server.env"
|
||||
|
||||
if not local.is_file():
|
||||
raise SystemExit(f"Missing {local}")
|
||||
|
||||
src = load_env(local)
|
||||
lines = [
|
||||
"# Generated by scripts/sync-server-env.py — do not commit",
|
||||
"# Deploy: ./scripts/deploy.sh uploads this to private/server.env",
|
||||
"",
|
||||
]
|
||||
|
||||
for key in SERVER_KEYS:
|
||||
if key in src:
|
||||
lines.append(f"{key}={src[key]}")
|
||||
|
||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
alert = src.get("ALERT_EMAIL", "?")
|
||||
print(f"Wrote {target.name} (ALERT_EMAIL={alert})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Send a test mail using local .env (this machine only).
|
||||
* Usage: php scripts/test-mail.php
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once "{$root}/config/mail.php";
|
||||
|
||||
$to = env('ALERT_EMAIL');
|
||||
if (!$to) {
|
||||
fwrite(STDERR, "ALERT_EMAIL not set in .env\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$from = env('SMTP_FROM', env('SMTP_USER'));
|
||||
$domain = env('DOMAIN', '0012.de');
|
||||
|
||||
$subject = "[{$domain}] SMTP test (local) " . gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$body = implode("\n", [
|
||||
'0012.de SMTP test — sent from local machine',
|
||||
'',
|
||||
'From header: ' . $from,
|
||||
'SMTP auth: ' . env('SMTP_USER'),
|
||||
'',
|
||||
'If you receive this, local SMTP settings work.',
|
||||
]);
|
||||
|
||||
try {
|
||||
sendMail($to, $subject, $body);
|
||||
echo "Sent test mail to {$to} (From: {$from})\n";
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, 'Failed: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user