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:
+6
-5
@@ -1,5 +1,6 @@
|
|||||||
# Kopie nach .env — niemals committen.
|
# Kopie nach .env — niemals committen.
|
||||||
# Alle Werte aus der wint.global Willkommens-Mail bzw. Plesk.
|
# Nur für diese Maschine: Deploy (FTP), lokale Tests.
|
||||||
|
# Server-Secrets → server.env (siehe server.env.example), nicht .env deployen!
|
||||||
|
|
||||||
# --- Hosting / Plesk (Classic 1.0, 0012.de) ---
|
# --- Hosting / Plesk (Classic 1.0, 0012.de) ---
|
||||||
PLESK_URL=https://ha01s019.org-dns.com:8443/
|
PLESK_URL=https://ha01s019.org-dns.com:8443/
|
||||||
@@ -23,15 +24,15 @@ NAMESERVER_1=ns1.org-ns.com
|
|||||||
NAMESERVER_2=ns2.org-ns.com
|
NAMESERVER_2=ns2.org-ns.com
|
||||||
|
|
||||||
# --- SMTP (Plesk-Mail auf diesem Webspace) ---
|
# --- SMTP (Plesk-Mail auf diesem Webspace) ---
|
||||||
# Für Monitoring-Alerts und Skripte, die über 0012.de mailen
|
# Login: Hauptpostfach admin@0012.de (Aliase: monitor@, alert@)
|
||||||
SMTP_HOST=ha01s019.org-dns.com
|
SMTP_HOST=ha01s019.org-dns.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_ENCRYPTION=tls
|
SMTP_ENCRYPTION=tls
|
||||||
SMTP_USER=
|
SMTP_USER=admin@0012.de
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
# Absender für System-Mails (Monitoring, Cron-Fehler)
|
# Absender für System-Mails — Alias monitor@0012.de
|
||||||
SMTP_FROM=monitor@0012.de
|
SMTP_FROM=monitor@0012.de
|
||||||
# Empfänger für Alarme
|
# Empfänger für Alarme (persönliche Adresse)
|
||||||
ALERT_EMAIL=
|
ALERT_EMAIL=
|
||||||
|
|
||||||
# --- Monitoring: Push von boka → Webspace ---
|
# --- Monitoring: Push von boka → Webspace ---
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
|
server.env
|
||||||
*.log
|
*.log
|
||||||
/vendor/
|
/vendor/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
|||||||
+52
-2
@@ -42,6 +42,34 @@ DNS-Einträge nur für Domains mit Bezug zum Paket oder über wint.global bestel
|
|||||||
|
|
||||||
Domain-Delegation kann nach Registration bis zu **24 Stunden** dauern. Bis dahin ggf. temporäre URL aus dem Kundenbereich nutzen.
|
Domain-Delegation kann nach Registration bis zu **24 Stunden** dauern. Bis dahin ggf. temporäre URL aus dem Kundenbereich nutzen.
|
||||||
|
|
||||||
|
### DNS-Zugang (Classic 1.0)
|
||||||
|
|
||||||
|
Bei wint.global liegt die **DNS-Zone oft beim Hoster** (`ns1.org-ns.com`), nicht beim Kunden in Plesk. SPF und DMARC sind gesetzt; **DKIM-TXT-Records fehlen öffentlich**, obwohl DKIM in Plesk aktiviert ist — typisch, wenn der Hoster DNS pflegt und die Mail-Records nicht synchronisiert hat.
|
||||||
|
|
||||||
|
**Du musst DNS nicht selbst pflegen.** Optionen:
|
||||||
|
|
||||||
|
1. **Support-Ticket** an support@wint.global — DKIM-DNS für `0012.de` hinterlegen (siehe unten)
|
||||||
|
2. **Workaround:** `ALERT_EMAIL` auf Gmail o. ä. setzen, solange Tuta/strenge Empfänger blockieren
|
||||||
|
|
||||||
|
#### Support-Ticket (DKIM)
|
||||||
|
|
||||||
|
In Plesk (ohne DNS-Rechte geht das trotzdem):
|
||||||
|
|
||||||
|
**Mail → Mail-Einstellungen → „Externe DNS konfigurieren“** — zwei TXT-Records kopieren und an den Support schicken, z. B.:
|
||||||
|
|
||||||
|
> Bitte folgende TXT-Records für 0012.de in der DNS-Zone ergänzen (DKIM ist in Plesk aktiv, Records fehlen in DNS):
|
||||||
|
>
|
||||||
|
> - `default._domainkey.0012.de` → *(Wert aus Plesk)*
|
||||||
|
> - `_domainkey.0012.de` → *(Wert aus Plesk)*
|
||||||
|
|
||||||
|
Prüfung danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short TXT default._domainkey.0012.de @ns1.org-ns.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Kundennummer **26064** angeben.
|
||||||
|
|
||||||
## TLS / HTTPS
|
## TLS / HTTPS
|
||||||
|
|
||||||
Konfiguration in Plesk (Stand 2026-06-11):
|
Konfiguration in Plesk (Stand 2026-06-11):
|
||||||
@@ -73,10 +101,32 @@ curl -sI https://0012.de/ # → 200, HSTS-Header
|
|||||||
|
|
||||||
**Sicherheit:** Nach 5 fehlgeschlagenen Login-Versuchen wird die Client-IP für 10 Minuten gesperrt (FTP, Webmail, E-Mail).
|
**Sicherheit:** Nach 5 fehlgeschlagenen Login-Versuchen wird die Client-IP für 10 Minuten gesperrt (FTP, Webmail, E-Mail).
|
||||||
|
|
||||||
|
### Postfächer 0012.de (Stand 2026-06-11)
|
||||||
|
|
||||||
|
| Adresse | Rolle |
|
||||||
|
|---------|--------|
|
||||||
|
| `admin@0012.de` | Hauptpostfach, **SMTP-Login** für Skripte |
|
||||||
|
| `monitor@0012.de` | Alias — Absender Monitoring (`SMTP_FROM`) |
|
||||||
|
| `alert@0012.de` | Alias — optional alternativer Absender |
|
||||||
|
|
||||||
|
Webmail / SMTP-Auth: immer die **Hauptadresse** + Postfach-Passwort, nicht den Alias-Namen allein.
|
||||||
|
|
||||||
|
### Authentifizierung (SPF / DKIM / DMARC)
|
||||||
|
|
||||||
|
| | Status |
|
||||||
|
|---|--------|
|
||||||
|
| **SPF** | ✅ gesetzt (Hoster) — nichts tun |
|
||||||
|
| **DKIM** | ⚠️ in Plesk an, **DNS-Record fehlt** — Support-Ticket (kein DNS-Zugang) |
|
||||||
|
| **DMARC** | ✅ `v=DMARC1;p=none;` (Hoster) — nichts tun |
|
||||||
|
|
||||||
|
DMARC ist optional zum Start (`p=none` = nur Reports, keine Ablehnung). Erst nach SPF/DKIM-Stabilisierung auf `quarantine`/`reject` erhöhen. Optional `rua=mailto:admin@0012.de` für Aggregate-Reports ergänzen.
|
||||||
|
|
||||||
|
Prüfung: [mail-tester.com](https://www.mail-tester.com) oder Test mit `php scripts/test-mail.php`.
|
||||||
|
|
||||||
### Geplante Nutzung
|
### Geplante Nutzung
|
||||||
|
|
||||||
1. **Monitoring-Alerts** — z. B. `monitor@0012.de` → persönliche Adresse
|
1. **Monitoring-Alerts** — `monitor@0012.de` → `ALERT_EMAIL` via `sendAlert()` in `config/mail.php`
|
||||||
2. **SMTP für Skripte** — boka und andere Hosts senden über TLS/587 (Relay nur für autorisierte Absender konfigurieren)
|
2. **SMTP von boka** — siehe [`todo/11-boka-smtp-relay.md`](todo/11-boka-smtp-relay.md)
|
||||||
3. **Mail-Hosting** — Postfächer für Domains ohne eigenen Mailserver (MX auf 0012.de bzw. wint-NS)
|
3. **Mail-Hosting** — Postfächer für Domains ohne eigenen Mailserver (MX auf 0012.de bzw. wint-NS)
|
||||||
|
|
||||||
## Geplante Aufgaben (Cron)
|
## Geplante Aufgaben (Cron)
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ Offene Arbeiten: [`todo/README.md`](todo/README.md)
|
|||||||
|
|
||||||
## Schnellstart (wenn eingerichtet)
|
## Schnellstart (wenn eingerichtet)
|
||||||
|
|
||||||
1. `.env.example` → `.env` kopieren und ausfüllen
|
1. `.env.example` → `.env` kopieren und ausfüllen (lokal, FTP)
|
||||||
2. `./scripts/deploy.sh` — spielt `public/` nach `httpdocs/`, Config und `.env` nach `private/`
|
2. `python3 scripts/sync-server-env.py` → erzeugt `server.env` für den Webspace
|
||||||
3. In Plesk: geplante Aufgabe für `monitor.php` (Cron-Stil, z. B. `*/10 * * * *`)
|
3. `./scripts/deploy.sh` — spielt `public/` + `config/` + `server.env` hoch (**.env bleibt lokal**)
|
||||||
4. Auf boka: Push-Skript + Crontab (siehe `todo/04-monitoring-push-boka.md`)
|
4. In Plesk: geplante Aufgabe für `monitor.php` (Cron-Stil, z. B. `*/10 * * * *`)
|
||||||
|
5. Auf boka: Push-Skript + Crontab (siehe `todo/04-monitoring-push-boka.md`)
|
||||||
|
|||||||
+21
-4
@@ -3,8 +3,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load configuration from private/.env (outside document root).
|
* Load configuration from private/server.env (outside document root).
|
||||||
* Called from httpdocs/*.php via require on private/config/bootstrap.php.
|
* Local .env is never deployed — see server.env.example.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function bootstrap(): array
|
function bootstrap(): array
|
||||||
@@ -15,12 +15,29 @@ function bootstrap(): array
|
|||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
$envFile = dirname(__DIR__) . '/.env';
|
$config = loadEnvFile(envFilePath());
|
||||||
$config = loadEnvFile($envFile);
|
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function envFilePath(): string
|
||||||
|
{
|
||||||
|
$baseDir = dirname(__DIR__);
|
||||||
|
|
||||||
|
// Webspace: private/config/bootstrap.php → private/server.env
|
||||||
|
if (basename($baseDir) === 'private') {
|
||||||
|
return $baseDir . '/server.env';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local repo: .env on this machine (never deployed)
|
||||||
|
$local = $baseDir . '/.env';
|
||||||
|
if (is_readable($local)) {
|
||||||
|
return $local;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseDir . '/server.env';
|
||||||
|
}
|
||||||
|
|
||||||
function env(string $key, ?string $default = null): ?string
|
function env(string $key, ?string $default = null): ?string
|
||||||
{
|
{
|
||||||
$value = bootstrap()[$key] ?? null;
|
$value = bootstrap()[$key] ?? null;
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send mail via SMTP (AUTH LOGIN + STARTTLS).
|
||||||
|
* Uses SMTP_* and SMTP_FROM from private/.env.
|
||||||
|
*/
|
||||||
|
function sendMail(string $to, string $subject, string $body, ?string $from = null): void
|
||||||
|
{
|
||||||
|
$host = env('SMTP_HOST');
|
||||||
|
$port = (int) (env('SMTP_PORT') ?: 587);
|
||||||
|
$user = env('SMTP_USER');
|
||||||
|
$pass = env('SMTP_PASSWORD');
|
||||||
|
$from = $from ?? env('SMTP_FROM') ?? $user;
|
||||||
|
|
||||||
|
if (!$host || !$user || !$pass || !$from) {
|
||||||
|
throw new RuntimeException('SMTP not configured — set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryption = strtolower(env('SMTP_ENCRYPTION') ?? 'tls');
|
||||||
|
$socket = connectSmtp($host, $port, $encryption);
|
||||||
|
|
||||||
|
$fromHeader = $from ?? env('SMTP_FROM') ?? $user;
|
||||||
|
// Envelope sender must be the authenticated mailbox (aliases are header-only on Plesk).
|
||||||
|
$envelopeFrom = $user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
expectCode($socket, 220);
|
||||||
|
sendCommand($socket, 'EHLO ' . gethostname(), 250);
|
||||||
|
|
||||||
|
if ($encryption === 'tls') {
|
||||||
|
sendCommand($socket, 'STARTTLS', 220);
|
||||||
|
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
throw new RuntimeException('STARTTLS failed');
|
||||||
|
}
|
||||||
|
sendCommand($socket, 'EHLO ' . gethostname(), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCommand($socket, 'AUTH LOGIN', 334);
|
||||||
|
sendCommand($socket, base64_encode($user), 334);
|
||||||
|
sendCommand($socket, base64_encode($pass), 235);
|
||||||
|
|
||||||
|
sendCommand($socket, 'MAIL FROM:<' . $envelopeFrom . '>', 250);
|
||||||
|
sendCommand($socket, 'RCPT TO:<' . $to . '>', 250);
|
||||||
|
sendCommand($socket, 'DATA', 354);
|
||||||
|
|
||||||
|
$message = buildMessage($fromHeader, $to, $subject, $body);
|
||||||
|
fwrite($socket, $message);
|
||||||
|
expectCode($socket, 250);
|
||||||
|
|
||||||
|
sendCommand($socket, 'QUIT', 221);
|
||||||
|
} finally {
|
||||||
|
fclose($socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return resource */
|
||||||
|
function connectSmtp(string $host, int $port, string $encryption)
|
||||||
|
{
|
||||||
|
$target = $encryption === 'ssl'
|
||||||
|
? "ssl://{$host}:{$port}"
|
||||||
|
: "tcp://{$host}:{$port}";
|
||||||
|
|
||||||
|
$socket = @stream_socket_client(
|
||||||
|
$target,
|
||||||
|
$errno,
|
||||||
|
$errstr,
|
||||||
|
30,
|
||||||
|
STREAM_CLIENT_CONNECT,
|
||||||
|
stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($socket === false) {
|
||||||
|
throw new RuntimeException("SMTP connect failed: {$errstr} ({$errno})");
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_timeout($socket, 30);
|
||||||
|
|
||||||
|
return $socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $socket */
|
||||||
|
function sendCommand($socket, string $command, int $expectedCode): void
|
||||||
|
{
|
||||||
|
fwrite($socket, $command . "\r\n");
|
||||||
|
expectCode($socket, $expectedCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $socket */
|
||||||
|
function expectCode($socket, int $expectedCode): void
|
||||||
|
{
|
||||||
|
$response = readResponse($socket);
|
||||||
|
$code = (int) substr($response, 0, 3);
|
||||||
|
|
||||||
|
if ($code !== $expectedCode) {
|
||||||
|
throw new RuntimeException("SMTP error (expected {$expectedCode}): {$response}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $socket */
|
||||||
|
function readResponse($socket): string
|
||||||
|
{
|
||||||
|
$response = '';
|
||||||
|
|
||||||
|
while (($line = fgets($socket, 515)) !== false) {
|
||||||
|
$response .= $line;
|
||||||
|
if (isset($line[3]) && $line[3] === ' ') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage(string $from, string $to, string $subject, string $body): string
|
||||||
|
{
|
||||||
|
$domain = env('DOMAIN', '0012.de');
|
||||||
|
$date = gmdate('D, d M Y H:i:s') . ' +0000';
|
||||||
|
$messageId = sprintf('<%s@%s>', bin2hex(random_bytes(8)), $domain);
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
"From: {$from}",
|
||||||
|
"To: {$to}",
|
||||||
|
"Subject: {$subject}",
|
||||||
|
"Date: {$date}",
|
||||||
|
"Message-ID: {$messageId}",
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: 8bit',
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAlert(string $subject, string $body): void
|
||||||
|
{
|
||||||
|
$to = env('ALERT_EMAIL');
|
||||||
|
|
||||||
|
if (!$to) {
|
||||||
|
throw new RuntimeException('ALERT_EMAIL not set in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMail($to, $subject, $body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off SMTP test from the webspace (same path as monitor.php cron).
|
||||||
|
* GET /mail-test.php?token=MONITOR_INGEST_TOKEN
|
||||||
|
* Remove or restrict after verification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/private/config/mail.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$token = $_GET['token'] ?? '';
|
||||||
|
$expected = env('MONITOR_INGEST_TOKEN') ?? '';
|
||||||
|
|
||||||
|
if ($expected === '' || !hash_equals($expected, $token)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'forbidden']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = env('ALERT_EMAIL');
|
||||||
|
if (!$to) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'ALERT_EMAIL not set']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = env('DOMAIN', '0012.de');
|
||||||
|
$subject = "[{$domain}] SMTP test (server) " . gmdate('Y-m-d H:i:s') . ' UTC';
|
||||||
|
$body = implode("\n", [
|
||||||
|
'0012.de SMTP test — sent from webspace PHP (cron path)',
|
||||||
|
'',
|
||||||
|
'From header: ' . env('SMTP_FROM'),
|
||||||
|
'SMTP auth: ' . env('SMTP_USER'),
|
||||||
|
'',
|
||||||
|
'If you receive this, server-side mail works.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendMail($to, $subject, $body);
|
||||||
|
echo json_encode(['ok' => true, 'to' => $to, 'from' => env('SMTP_FROM')]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
+28
-3
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
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}")
|
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:
|
def upload_tree(ftp: ftplib.FTP, local_dir: Path, remote_base: str) -> None:
|
||||||
remote_base = remote_base.strip("/")
|
remote_base = remote_base.strip("/")
|
||||||
for path in sorted(local_dir.rglob("*")):
|
for path in sorted(local_dir.rglob("*")):
|
||||||
@@ -101,6 +116,13 @@ def main() -> None:
|
|||||||
env = load_env(ROOT / ".env")
|
env = load_env(ROOT / ".env")
|
||||||
require_env(env, "FTP_HOST", "FTP_USER", "FTP_PASSWORD")
|
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_web = env.get("FTP_REMOTE_PATH", "httpdocs").strip("/")
|
||||||
remote_private = env.get("FTP_PRIVATE_PATH", "private").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())
|
config_files = sum(1 for p in (ROOT / "config").rglob("*") if p.is_file())
|
||||||
print(f" public/ ({public_files} files) → {remote_web}/")
|
print(f" public/ ({public_files} files) → {remote_web}/")
|
||||||
print(f" config/ ({config_files} files) → {remote_private}/config/")
|
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(f" (mkdir) {remote_private}/data/")
|
||||||
|
print(" .env stays local — not deployed")
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
return
|
return
|
||||||
@@ -122,7 +146,8 @@ def main() -> None:
|
|||||||
upload_tree(ftp, ROOT / "public", remote_web)
|
upload_tree(ftp, ROOT / "public", remote_web)
|
||||||
upload_tree(ftp, ROOT / "config", f"{remote_private}/config")
|
upload_tree(ftp, ROOT / "config", f"{remote_private}/config")
|
||||||
ensure_remote_dir(ftp, f"{remote_private}/data")
|
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.")
|
print("\nDone.")
|
||||||
finally:
|
finally:
|
||||||
ftp.quit()
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Kopie nach server.env — niemals committen.
|
||||||
|
# Wird nach private/server.env auf dem Webspace deployed.
|
||||||
|
# Nur Werte, die PHP-Skripte auf 0012.de brauchen (kein FTP/Plesk).
|
||||||
|
|
||||||
|
DOMAIN=0012.de
|
||||||
|
|
||||||
|
# SMTP (0012.de Postfach)
|
||||||
|
SMTP_HOST=ha01s019.org-dns.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_ENCRYPTION=tls
|
||||||
|
SMTP_USER=admin@0012.de
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=monitor@0012.de
|
||||||
|
|
||||||
|
# Alarm-Empfänger
|
||||||
|
ALERT_EMAIL=
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
MONITOR_INGEST_TOKEN=
|
||||||
|
MONITOR_INGEST_URL=https://0012.de/ingest.php
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
@@ -29,7 +29,7 @@ Lokales Repo mit dem Webspace verbinden und eine deploybare Grundstruktur anlege
|
|||||||
├── httpdocs/ ← public/
|
├── httpdocs/ ← public/
|
||||||
│ └── index.php
|
│ └── index.php
|
||||||
└── private/ ← nicht öffentlich
|
└── private/ ← nicht öffentlich
|
||||||
├── .env
|
├── server.env ← Server-Secrets (deployed, nicht .env!)
|
||||||
├── config/bootstrap.php
|
├── config/bootstrap.php
|
||||||
└── data/ ← später last-checks.json
|
└── data/ ← später last-checks.json
|
||||||
```
|
```
|
||||||
|
|||||||
+52
-20
@@ -1,45 +1,77 @@
|
|||||||
# 02 — E-Mail & SMTP
|
# 02 — E-Mail & SMTP
|
||||||
|
|
||||||
**Priorität:** high
|
**Priorität:** high
|
||||||
|
**Status:** erledigt (2026-06-11)
|
||||||
**Abhängigkeiten:** 01
|
**Abhängigkeiten:** 01
|
||||||
**Blockiert:** 03 (Alarmierung)
|
**Blockiert:** 03 (Alarmierung)
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Mail-Infrastruktur auf 0012.de für Monitoring-Alerts, Skript-Versand und spätere Fremddomains.
|
Mail-Infrastruktur auf 0012.de für Monitoring-Alerts und Skript-Versand.
|
||||||
|
|
||||||
|
Relay von **boka** → ausgelagert nach [`11-boka-smtp-relay.md`](11-boka-smtp-relay.md).
|
||||||
|
|
||||||
|
## Postfach-Setup (Plesk)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Postfach | `admin@0012.de` |
|
||||||
|
| Aliase | `monitor@0012.de`, `alert@0012.de` |
|
||||||
|
| SMTP-Login | `admin@0012.de` (Hauptpostfach, nicht Alias) |
|
||||||
|
| Absender Monitoring | `SMTP_FROM=monitor@0012.de` |
|
||||||
|
| Alarm-Empfänger | `ALERT_EMAIL` → persönliche Adresse |
|
||||||
|
|
||||||
## Checkliste
|
## Checkliste
|
||||||
|
|
||||||
### Postfächer auf 0012.de
|
### Postfächer auf 0012.de
|
||||||
|
|
||||||
- [ ] Postfach `monitor@0012.de` (oder `alerts@0012.de`) anlegen
|
- [x] `admin@0012.de` mit Aliasen `monitor@`, `alert@`
|
||||||
- [ ] Passwort in `.env` → `SMTP_USER`, `SMTP_PASSWORD`
|
- [x] Passwort in `.env` → `SMTP_USER`, `SMTP_PASSWORD`
|
||||||
- [ ] `ALERT_EMAIL` auf persönliche Adresse setzen
|
- [x] `ALERT_EMAIL` gesetzt
|
||||||
- [ ] `SMTP_FROM=monitor@0012.de` setzen
|
- [x] `SMTP_FROM=monitor@0012.de`
|
||||||
- [ ] Test: Webmail-Login auf `ha01s019.org-dns.com`
|
- [x] Webmail-Login auf `ha01s019.org-dns.com`
|
||||||
|
|
||||||
### SMTP für Skripte
|
### SMTP für Skripte (0012.de)
|
||||||
|
|
||||||
- [ ] Port 587 + STARTTLS testen (von lokalem Rechner / boka)
|
- [x] `config/mail.php` — `sendMail()`, `sendAlert()`
|
||||||
- [ ] SPF für `0012.de` in Plesk DNS prüfen/setzen
|
- [x] `scripts/test-mail.php` — CLI-Test
|
||||||
- [ ] DKIM in Plesk aktivieren (Deliverability)
|
- [x] Port 587 + STARTTLS getestet (`php scripts/test-mail.php` → OK)
|
||||||
- [ ] DMARC optional: `_dmarc.0012.de` mit `p=none` zum Start
|
- [x] SPF gesetzt (Hoster-DNS)
|
||||||
|
- [x] DKIM in Plesk aktiviert
|
||||||
|
- [ ] DKIM in **öffentlicher DNS** — fehlt; Hoster per Ticket (kein DNS-Zugang im Paket)
|
||||||
|
- [x] DMARC vorhanden (Hoster-DNS)
|
||||||
|
|
||||||
### Relay von boka (später)
|
### Technische Notizen
|
||||||
|
|
||||||
- [ ] Entscheiden: dediziertes Relay-Postfach `relay@0012.de` vs. App-spezifische Absender
|
- **Envelope vs. From:** SMTP-Login als `admin@`, `MAIL FROM` ebenfalls `admin@`; sichtbarer Absender bleibt `monitor@` (Alias). Plesk verwirft sonst oft still nach `250 OK`.
|
||||||
- [ ] In Plesk: ausgehende Mail-Limits / Relay-Regeln prüfen (Hoster-Doku)
|
- **Test lokal:** `php scripts/test-mail.php` (liest `.env`)
|
||||||
- [ ] boka-Apps: zentraler SMTP-Block in Docker/env (Host, Port, User, Pass)
|
- **Test vom Webspace:** `mail-test.php?token=…` (liest `private/server.env`)
|
||||||
|
- **Tuta/strenge Empfänger:** bis DKIM-DNS steht → `ALERT_EMAIL` Gmail o. ä.
|
||||||
|
- **Kein DNS-Zugang:** DKIM-Records aus Plesk Mail-Einstellungen an wint-Support — siehe [`../HOSTING.md`](../HOSTING.md#dns-zugang-classic-10)
|
||||||
|
|
||||||
### Cron-Benachrichtigungen Plesk
|
## `.env` (Beispiel)
|
||||||
|
|
||||||
- [ ] Geplante Aufgaben: „Nur Fehler“ für PHP-Fatal-Errors — ergänzt App-eigene Alerts, ersetzt sie nicht
|
```env
|
||||||
|
SMTP_USER=admin@0012.de
|
||||||
|
SMTP_FROM=monitor@0012.de
|
||||||
|
ALERT_EMAIL=you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/test-mail.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach Deploy: `server.env` auf Server, Test lokal mit `.env`.
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- Testmail von PHP (`mail()` oder PHPMailer) an `ALERT_EMAIL` kommt an
|
- [x] Testmail an `ALERT_EMAIL` kommt an
|
||||||
- SPF/DKIM ohne Hard-Fail in Mail-Header-Test (mail-tester.com o. ä.)
|
- [ ] SPF/DKIM ohne Hard-Fail (mail-tester.com o. ä.) — optional
|
||||||
|
|
||||||
## Referenz
|
## Referenz
|
||||||
|
|
||||||
- [`../HOSTING.md`](../HOSTING.md) — SMTP-Host `ha01s019.org-dns.com`
|
- [`../HOSTING.md`](../HOSTING.md)
|
||||||
|
- [`../config/mail.php`](../config/mail.php)
|
||||||
|
- boka-Relay: [`11-boka-smtp-relay.md`](11-boka-smtp-relay.md)
|
||||||
|
|||||||
+15
-1
@@ -17,10 +17,24 @@ Monitoring automatisch alle 5–10 Minuten ausführen.
|
|||||||
- [ ] Ausführen: **Cron-Stil**
|
- [ ] Ausführen: **Cron-Stil**
|
||||||
- [ ] Expression: `*/10 * * * *` (Pull-Checks alle 10 Min)
|
- [ ] Expression: `*/10 * * * *` (Pull-Checks alle 10 Min)
|
||||||
- [ ] Beschreibung: z. B. „0012 external monitoring pull“
|
- [ ] Beschreibung: z. B. „0012 external monitoring pull“
|
||||||
- [ ] Benachrichtigen: **Nur Fehler** (PHP-Crash → Plesk-Mail)
|
- [ ] Benachrichtigen: **Nur Fehler** (siehe unten)
|
||||||
- [ ] **Jetzt ausführen** — Exit-Code / Log prüfen
|
- [ ] **Jetzt ausführen** — Exit-Code / Log prüfen
|
||||||
- [ ] Optional zweite Aufgabe für leichtgewichtigen Heartbeat-Only-Check (`*/5`) — nur wenn von monitor.php getrennt
|
- [ ] Optional zweite Aufgabe für leichtgewichtigen Heartbeat-Only-Check (`*/5`) — nur wenn von monitor.php getrennt
|
||||||
|
|
||||||
|
## Benachrichtigen: „Nur Fehler“
|
||||||
|
|
||||||
|
Erst beim Anlegen der geplanten Aufgabe in Plesk relevant (zusammen mit `monitor.php`).
|
||||||
|
|
||||||
|
| Option | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| **Nicht benachrichtigen** | Cron läuft still; Crash fällt nicht auf |
|
||||||
|
| **Nur Fehler** | Plesk mailt nur bei Skript-Fehler (PHP-Fatal, Exit ≠ 0, stderr) |
|
||||||
|
| **Jedes Mal** | Mail bei jeder Ausführung — bei `*/10` unbrauchbar |
|
||||||
|
|
||||||
|
**Empfehlung: „Nur Fehler“** — meldet, wenn `monitor.php` auf 0012.de selbst kaputt ist.
|
||||||
|
|
||||||
|
**Getrennt von Monitoring-Alerts:** Ausfall von `schwenk.online` kommt aus `sendAlert()` → `ALERT_EMAIL`. Plesk-Cron-Mail betrifft nur die Cron-Ausführung auf dem Webspace.
|
||||||
|
|
||||||
## Cron-Referenz
|
## Cron-Referenz
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 11 — SMTP-Relay von boka
|
||||||
|
|
||||||
|
**Priorität:** medium
|
||||||
|
**Status:** offen
|
||||||
|
**Abhängigkeiten:** [02-email-smtp.md](02-email-smtp.md) (erledigt)
|
||||||
|
**Blockiert:** boka-Apps, die über 0012.de mailen sollen
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Prozesse auf **boka** (Docker-Stacks, Cron-Skripte) senden ausgehende Mail über den Plesk-SMTP von `0012.de`.
|
||||||
|
|
||||||
|
## Voraussetzungen (0012.de — erledigt in Todo 02)
|
||||||
|
|
||||||
|
- Postfach `admin@0012.de` mit Aliasen `monitor@`, `alert@`
|
||||||
|
- SMTP TLS/587 auf `ha01s019.org-dns.com`
|
||||||
|
- DKIM aktiv
|
||||||
|
|
||||||
|
## Checkliste
|
||||||
|
|
||||||
|
- [ ] Entscheiden: dediziertes Relay-Postfach vs. `admin@` + alias From-Adressen
|
||||||
|
- [ ] In Plesk: ausgehende Mail-Limits / Relay-Regeln prüfen (Hoster-Doku)
|
||||||
|
- [ ] SPF für `0012.de` verifizieren (boka-IPs nur wenn direkt sendend — bei Relay nicht nötig)
|
||||||
|
- [ ] boka: zentraler SMTP-Block (Docker env / `.env` auf boka)
|
||||||
|
- `SMTP_HOST=ha01s019.org-dns.com`
|
||||||
|
- `SMTP_PORT=587`
|
||||||
|
- `SMTP_USER=admin@0012.de`
|
||||||
|
- `SMTP_PASSWORD=…` (gleiches Postfach oder App-Passwort)
|
||||||
|
- [ ] Test von boka: `curl`/swaks oder kleines Script → Mail an persönliche Adresse
|
||||||
|
- [ ] Pro Stack: From-Adresse festlegen (`monitor@0012.de`, `alert@0012.de`, …)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- Mindestens ein Dienst auf boka sendet erfolgreich über 0012.de-SMTP
|
||||||
|
- Keine Mails im Spam (DKIM pass)
|
||||||
|
|
||||||
|
## Referenz
|
||||||
|
|
||||||
|
- [`../HOSTING.md`](../HOSTING.md) — SMTP-Zugang
|
||||||
|
- [`../config/mail.php`](../config/mail.php) — Referenz-Implementierung (PHP)
|
||||||
+4
-1
@@ -5,7 +5,7 @@ Nummerierte Todos für den 0012.de-Webspace. Abhängigkeiten beachten.
|
|||||||
| # | Datei | Priorität | Kurzbeschreibung |
|
| # | Datei | Priorität | Kurzbeschreibung |
|
||||||
|---|-------|-----------|------------------|
|
|---|-------|-----------|------------------|
|
||||||
| 01 | [01-bootstrap-env-deploy.md](01-bootstrap-env-deploy.md) | **high** | `.env`, Repo-Struktur, erstes FTP-Deployment |
|
| 01 | [01-bootstrap-env-deploy.md](01-bootstrap-env-deploy.md) | **high** | `.env`, Repo-Struktur, erstes FTP-Deployment |
|
||||||
| 02 | [02-email-smtp.md](02-email-smtp.md) | **high** | Postfächer, SMTP für Alerts und Relay |
|
| 02 | [02-email-smtp.md](02-email-smtp.md) | **high** | Postfächer, SMTP für Alerts (0012.de) |
|
||||||
| 03 | [03-monitoring-pull-phase1.md](03-monitoring-pull-phase1.md) | **high** | `monitor.php`, HTTPS/SSL-Checks, Alarm |
|
| 03 | [03-monitoring-pull-phase1.md](03-monitoring-pull-phase1.md) | **high** | `monitor.php`, HTTPS/SSL-Checks, Alarm |
|
||||||
| 04 | [04-monitoring-push-boka.md](04-monitoring-push-boka.md) | **medium** | Push von boka → `ingest.php` |
|
| 04 | [04-monitoring-push-boka.md](04-monitoring-push-boka.md) | **medium** | Push von boka → `ingest.php` |
|
||||||
| 05 | [05-plesk-cron.md](05-plesk-cron.md) | **high** | Geplante Aufgaben in Plesk |
|
| 05 | [05-plesk-cron.md](05-plesk-cron.md) | **high** | Geplante Aufgaben in Plesk |
|
||||||
@@ -14,11 +14,14 @@ Nummerierte Todos für den 0012.de-Webspace. Abhängigkeiten beachten.
|
|||||||
| 08 | [08-monitoring-phase2.md](08-monitoring-phase2.md) | **medium** | API-Health, Latenz, Backup-Heartbeat |
|
| 08 | [08-monitoring-phase2.md](08-monitoring-phase2.md) | **medium** | API-Health, Latenz, Backup-Heartbeat |
|
||||||
| 09 | [09-monitoring-phase3.md](09-monitoring-phase3.md) | **low** | Rest-Domains, Dashboard, Header-Checks |
|
| 09 | [09-monitoring-phase3.md](09-monitoring-phase3.md) | **low** | Rest-Domains, Dashboard, Header-Checks |
|
||||||
| 10 | [10-optional-plesk-git-composer.md](10-optional-plesk-git-composer.md) | backlog | Plesk Git / Composer evaluieren |
|
| 10 | [10-optional-plesk-git-composer.md](10-optional-plesk-git-composer.md) | backlog | Plesk Git / Composer evaluieren |
|
||||||
|
| 11 | [11-boka-smtp-relay.md](11-boka-smtp-relay.md) | **medium** | boka-Apps über 0012.de-SMTP |
|
||||||
|
|
||||||
## Backlog (ohne Priorität)
|
## Backlog (ohne Priorität)
|
||||||
|
|
||||||
Todo **10** — Plesk Git-Deploy und Composer; dokumentiert in [`../PLESK.md`](../PLESK.md), kein Blocker für 02–09.
|
Todo **10** — Plesk Git-Deploy und Composer; dokumentiert in [`../PLESK.md`](../PLESK.md), kein Blocker für 02–09.
|
||||||
|
|
||||||
|
Todo **11** — boka SMTP-Relay; abhängig von erledigtem **02**, kein Blocker für **03**.
|
||||||
|
|
||||||
## Empfohlene Reihenfolge
|
## Empfohlene Reihenfolge
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user