ops: snolla SMTP rollout 2026-08-14 — monitor deployed (VDS cron+ntfy), msmtp/backup-mail on new creds, pilonuxt compose tag 954e29e
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Admin Task Board
|
# Admin Task Board
|
||||||
_Updated: 2026-08-14 — ⚪ **[snolla-smtp-send-monitor-vds] заведена** (по запросу vitya после SMTP-инцидента 12–14.08). Периодический мониторинг доставки писем форм snolla-сайтов, живёт на VDS (cron + SMTP auth-тест с боевыми кредами `e-16513832@yandex.ru`, алерт ntfy; parity-чек Portainer env vs канон). Спека: `.tasks/snolla-smtp-send-monitor-vds.md`._
|
_Updated: 2026-08-14 — 🟢 **[snolla-smtp-send-monitor-vds] DONE** (по запросу vitya). Монитор SMTP-отправки snolla-сайтов задеплоен на VDS: `/root/snolla-smtp-monitor/monitor.py` (smtp-auth + weekly real-send + parity Portainer env 7 стеков) + cron 08:00 MSK; негатив-тест пройден (535 → ntfy-alert HTTP 200); parity зелёный после роллаута SMTP. Копия: `.admin/scripts/snolla-smtp-monitor/`. Попутно: msmtp на VDS (бэкап-отчёты) переведён на новые креды + From в run.sh (был мёртвый noreply@snolla.com)._
|
||||||
_Updated: 2026-08-13 — 🟢 **[stostayer-web-deploy-0-3-23] CLOSED.** Запрос их прогера (письмо в `.agents/inbox/2026-08-13T08-43-41Z-stostayer-new.md`): calculator-баннер про цены при покупке запчастей + фикс краша (snolla loadCity/loadSite) + стек-фикс легаси (vue/vuex/vue-router/mariadb явные deps, nmHoistingLimits). Образ `stostayer-web:0.3.23` (коммиты `e5f7bd6`+`7e51cbf`+`d49b4bb`) собран (нюанс: offline-build не прошёл — в `.yarn/cache` нет linux-бинарей esbuild/rollup, собрал с временным `VERDACCIO_TOKEN` ARG/ENV), запушен 1 попыткой (слои already-exists → бана нет), стек Portainer 16 передеплоен 0.3.22→0.3.23 (HTTP=200, env re-supply). Verify с хоста: Focus II → 200 + баннер «актуальны при приобретении запасных частей», регресс `/`+ремонтная → 200, Focus IV (exactPrices=0) → баннера нет + алерт «уточняйте у мастеров» (всё по письму). Контейнер Up, RestartCount=0, логи чистые. Откат: PUT на 0.3.22. Ранбук обновлён (прод-тег, build-нюансы, порт :9000 в Portainer API). Всё: `.tasks/stostayer-web-deploy-0-3-23.md` + `.wiki/concepts/stostayer-web-deploy-runbook.md`._
|
_Updated: 2026-08-13 — 🟢 **[stostayer-web-deploy-0-3-23] CLOSED.** Запрос их прогера (письмо в `.agents/inbox/2026-08-13T08-43-41Z-stostayer-new.md`): calculator-баннер про цены при покупке запчастей + фикс краша (snolla loadCity/loadSite) + стек-фикс легаси (vue/vuex/vue-router/mariadb явные deps, nmHoistingLimits). Образ `stostayer-web:0.3.23` (коммиты `e5f7bd6`+`7e51cbf`+`d49b4bb`) собран (нюанс: offline-build не прошёл — в `.yarn/cache` нет linux-бинарей esbuild/rollup, собрал с временным `VERDACCIO_TOKEN` ARG/ENV), запушен 1 попыткой (слои already-exists → бана нет), стек Portainer 16 передеплоен 0.3.22→0.3.23 (HTTP=200, env re-supply). Verify с хоста: Focus II → 200 + баннер «актуальны при приобретении запасных частей», регресс `/`+ремонтная → 200, Focus IV (exactPrices=0) → баннера нет + алерт «уточняйте у мастеров» (всё по письму). Контейнер Up, RestartCount=0, логи чистые. Откат: PUT на 0.3.22. Ранбук обновлён (прод-тег, build-нюансы, порт :9000 в Portainer API). Всё: `.tasks/stostayer-web-deploy-0-3-23.md` + `.wiki/concepts/stostayer-web-deploy-runbook.md`._
|
||||||
_Updated: 2026-08-10 — 🟡 **[kreknin-repair-md3-rebuild] ребилд идёт, бэкапы ВОЗВРАЩЕНЫ.** Утро: vitya на месте, кабели перетыкнуты (sdb WD-WX32D12L8HTE вернулся, SMART PASSED, все 3 HDD на 6 Gbps), md3 rebuild старт 07:57 (`mdadm --add /dev/md3 /dev/sdb3`), md0/md1 починены (системные разделы добавлены во все 4 диска — «зелёные» в DSM), win10 resolved (RuToken re-enum, кнопка «Игнорировать»). **~11:40 отключение электричества в деревне** → NAS лёг (роутер/WAN жив — на UPS). Возврат 16:20: md3 собрался деградированным `[_UU]`, **sdb3 сам не вернулся** (прогресс rebuild НЕ переживает power-loss — контрольной точки нет), повторён `mdadm --add /dev/md3 /dev/sdb3` → recovery с 0.0%, ETA ~4-5 дн (разгон 3→10-25МБ/с). md0/md1 все 4 диска `[UUUU]` (утр. ремонт пережил ребут), md2/VMM здоров. **Бэкапы возвращены 2026-08-10 18:00 по указанию vitya (ДО ребилда — ребилд фоновый):** cron раскомментирован (vds-kzntsv 05:00 + books-vds 06:00), ручные прогоны ПОДТВЕРЖДЕНЫ на kreknin (vds-kzntsv 2026-08-10 66G / books-vds 15G, latest→08-10, 7 снапшотов у обоих). Находка: books-vds не доезжал до kreknin с 08-05 (08-06..08-09 + 08-10 06:05 rsync падал — timed out/no route; другой триггер не найден); застрявший ES-снапшот `daily-2026-08-10` удалён. Осталось: дождаться ребилда md3 → `btrfs scrub /volume1` → monitor на ребилд активен. Всё: `.tasks/kreknin-repair-md3-rebuild.md` + `.wiki/entities/kreknin-synology.md`._
|
_Updated: 2026-08-10 — 🟡 **[kreknin-repair-md3-rebuild] ребилд идёт, бэкапы ВОЗВРАЩЕНЫ.** Утро: vitya на месте, кабели перетыкнуты (sdb WD-WX32D12L8HTE вернулся, SMART PASSED, все 3 HDD на 6 Gbps), md3 rebuild старт 07:57 (`mdadm --add /dev/md3 /dev/sdb3`), md0/md1 починены (системные разделы добавлены во все 4 диска — «зелёные» в DSM), win10 resolved (RuToken re-enum, кнопка «Игнорировать»). **~11:40 отключение электричества в деревне** → NAS лёг (роутер/WAN жив — на UPS). Возврат 16:20: md3 собрался деградированным `[_UU]`, **sdb3 сам не вернулся** (прогресс rebuild НЕ переживает power-loss — контрольной точки нет), повторён `mdadm --add /dev/md3 /dev/sdb3` → recovery с 0.0%, ETA ~4-5 дн (разгон 3→10-25МБ/с). md0/md1 все 4 диска `[UUUU]` (утр. ремонт пережил ребут), md2/VMM здоров. **Бэкапы возвращены 2026-08-10 18:00 по указанию vitya (ДО ребилда — ребилд фоновый):** cron раскомментирован (vds-kzntsv 05:00 + books-vds 06:00), ручные прогоны ПОДТВЕРЖДЕНЫ на kreknin (vds-kzntsv 2026-08-10 66G / books-vds 15G, latest→08-10, 7 снапшотов у обоих). Находка: books-vds не доезжал до kreknin с 08-05 (08-06..08-09 + 08-10 06:05 rsync падал — timed out/no route; другой триггер не найден); застрявший ES-снапшот `daily-2026-08-10` удалён. Осталось: дождаться ребилда md3 → `btrfs scrub /volume1` → monitor на ребилд активен. Всё: `.tasks/kreknin-repair-md3-rebuild.md` + `.wiki/entities/kreknin-synology.md`._
|
||||||
_Updated: 2026-08-04 — 🔵 **[coord-loader-admin-api-client] координатор.** Я (admin) назначен координатором таски pilorama98 `loader-migrate-to-admin-api-client` (перевод `apps/loader` с прямого DB на `@snolla/admin-api-client` от snolla). Письма-интро отправлены обоим (pilorama98 — report готовности, snolla — краткое описание клиента). Предпосылка S1: **ApiKeys DDL прогнана на обоих боевых SNOLLA** (vds `MoreThenCms` + stostayer `stostayer`) — таблица + индексы + FK, RC=0, verify зелёный. См. `coord-loader-admin-api-client.md`._
|
_Updated: 2026-08-04 — 🔵 **[coord-loader-admin-api-client] координатор.** Я (admin) назначен координатором таски pilorama98 `loader-migrate-to-admin-api-client` (перевод `apps/loader` с прямого DB на `@snolla/admin-api-client` от snolla). Письма-интро отправлены обоим (pilorama98 — report готовности, snolla — краткое описание клиента). Предпосылка S1: **ApiKeys DDL прогнана на обоих боевых SNOLLA** (vds `MoreThenCms` + stostayer `stostayer`) — таблица + индексы + FK, RC=0, verify зелёный. См. `coord-loader-admin-api-client.md`._
|
||||||
@@ -60,9 +60,9 @@ _Updated: 2026-05-26 — заведена `bookva-tenant-cutover-prep` ⚪ (7-st
|
|||||||
|
|
||||||
_Updated: 2026-05-25 (`iis-migration-to-ruvds` 🟢 closed Phase 1 per user decision — 9/24 hostnames live на RUVDS, source IIS оставлен running. `migrate-elasticsearch-to-books-vds` 🟢 closed ранее сегодня. 16 IIS hostnames + LE renewal pipeline + decommission — descoped в Closure note, не отдельные tracker tasks.)_
|
_Updated: 2026-05-25 (`iis-migration-to-ruvds` 🟢 closed Phase 1 per user decision — 9/24 hostnames live на RUVDS, source IIS оставлен running. `migrate-elasticsearch-to-books-vds` 🟢 closed ранее сегодня. 16 IIS hostnames + LE renewal pipeline + decommission — descoped в Closure note, не отдельные tracker tasks.)_
|
||||||
|
|
||||||
## ⚪ [snolla-smtp-send-monitor-vds] — Периодический мониторинг SMTP-отправки писем форм snolla-сайтов с VDS (ловит тихие сбои почты типа 12–14.08.2026, когда формы отвечали «успешно», а письма молча не шли 2 дня), алерт через ntfy.
|
## 🟢 [snolla-smtp-send-monitor-vds] — DONE 2026-08-14 — периодический мониторинг SMTP-отправки писем форм snolla-сайтов с VDS (ловит тихие сбои почты типа 12–14.08.2026), алерт через ntfy. Деплой: `/root/snolla-smtp-monitor/monitor.py` (python3 stdlib: smtp-auth тест + weekly real-send + parity Portainer env 7 стеков) + cron `0 8 * * *` + креды `/root/.snolla-smtp-monitor.env` (600). Негатив-тест пройден (сломанный пароль → 535 → ntfy HTTP 200 → exit 1). Parity ЗЕЛЁНЫЙ после роллаута 2026-08-14. Копия скрипта: `.admin/scripts/snolla-smtp-monitor/monitor.py`.
|
||||||
|
|
||||||
**Status:** ready
|
**Status:** done
|
||||||
**Where I stopped:** (not started) — спецификация в `.tasks/snolla-smtp-send-monitor-vds.md` (дизайн: SMTP auth-тест + parity-чек Portainer env, вариант A = host-cron на VDS)
|
**Where I stopped:** (not started) — спецификация в `.tasks/snolla-smtp-send-monitor-vds.md` (дизайн: SMTP auth-тест + parity-чек Portainer env, вариант A = host-cron на VDS)
|
||||||
**Next action:** Собрать node-скрипт SMTP auth-теста (nodemailer verify на smtp.yandex.ru:465, креды `e-16513832@yandex.ru`/app-password из `pass snolla-smtp/full-env`) → положить на VDS (креды в `/root/.snolla-smtp-monitor.env` chmod 600) → cron ~08:00 MSK → негатив-тест: временно сломать пароль → ntfy-алерт приходит → parity-чек env Portainer-стеков vs канон.
|
**Next action:** Собрать node-скрипт SMTP auth-теста (nodemailer verify на smtp.yandex.ru:465, креды `e-16513832@yandex.ru`/app-password из `pass snolla-smtp/full-env`) → положить на VDS (креды в `/root/.snolla-smtp-monitor.env` chmod 600) → cron ~08:00 MSK → негатив-тест: временно сломать пароль → ntfy-алерт приходит → parity-чек env Portainer-стеков vs канон.
|
||||||
**Branch:** master
|
**Branch:** master
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
services:
|
services:
|
||||||
pilonuxt:
|
pilonuxt:
|
||||||
container_name: pilonuxt
|
container_name: pilonuxt
|
||||||
image: registry.kzntsv.site/pilonuxt:515ab99
|
image: registry.kzntsv.site/pilonuxt:954e29e # git-sha 954e29e (2026-08-14: SMTP e-16513832@yandex.ru app-password — Yandex disabled protocol access on noreply@snolla.com; was 515ab99)
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
177
scripts/snolla-smtp-monitor/monitor.py
Normal file
177
scripts/snolla-smtp-monitor/monitor.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""snolla-smtp-monitor — health check of snolla sites' SMTP mail sending (lives on vds-kzntsv).
|
||||||
|
|
||||||
|
Background: 2026-08-12..14 Yandex disabled SMTP for noreply@snolla.com → site forms kept
|
||||||
|
answering "success" while order emails silently died for 2 days (mailer swallows errors).
|
||||||
|
This monitor catches that class of failure at the next tick.
|
||||||
|
|
||||||
|
Checks (run daily via /etc/cron.d/snolla-smtp-monitor):
|
||||||
|
1. SMTP auth against smtp.yandex.ru:465 with the current snolla mailer creds
|
||||||
|
(catches 525 SMTP disabled / 535 invalid login / connection failure).
|
||||||
|
2. Config parity: SMTP_USER/SMTP_PASSWORD in Portainer stack envs of the env-driven
|
||||||
|
snolla sites must equal the expected creds (catches config drift before it breaks).
|
||||||
|
3. Weekly (Mon): real test send to OPS_NOTIFY_EMAIL (end-to-end proof through Yandex).
|
||||||
|
|
||||||
|
On failure: ntfy push (topic vds-ops) + exit 1. On success: log line only (silent).
|
||||||
|
|
||||||
|
Config: /root/.snolla-smtp-monitor.env (chmod 600; mirror of `pass snolla-smtp/full-env` +
|
||||||
|
ntfy + portainer sections of `pass vds-kzntsv/full-env`). Keys:
|
||||||
|
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS OPS_NOTIFY_EMAIL
|
||||||
|
NTFY_URL NTFY_USER NTFY_PASS NTFY_TOPIC
|
||||||
|
PORTAINER_URL PORTAINER_USER PORTAINER_PASS PORTAINER_STACKS(comma list of ids or names)
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import email.message
|
||||||
|
import json
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ENV_PATH = "/root/.snolla-smtp-monitor.env"
|
||||||
|
LOG_PATH = "/var/log/snolla-smtp-monitor.log"
|
||||||
|
|
||||||
|
# Env-driven snolla stacks (SMTP creds in Portainer stack env). pilonuxt (16) excluded —
|
||||||
|
# its config is baked into the image; covered by check 1.
|
||||||
|
DEFAULT_STACKS = [17, 18, 19, 20, 21, 22, 23]
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
cfg = {}
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
cfg[k.strip()] = v.strip()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def log(cfg, msg):
|
||||||
|
ts = datetime.datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
line = f"{ts} {msg}"
|
||||||
|
print(line, flush=True)
|
||||||
|
try:
|
||||||
|
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"log write failed: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def ntfy_alert(cfg, title, body):
|
||||||
|
try:
|
||||||
|
url = f"{cfg['NTFY_URL']}/{cfg.get('NTFY_TOPIC', 'vds-ops')}"
|
||||||
|
req = urllib.request.Request(url, data=body.encode(), method="POST")
|
||||||
|
token = base64.b64encode(f"{cfg['NTFY_USER']}:{cfg['NTFY_PASS']}".encode()).decode()
|
||||||
|
req.add_header("Authorization", "Basic " + token)
|
||||||
|
# urllib encodes header VALUES as latin-1 → keep title ASCII (emoji goes in body)
|
||||||
|
req.add_header("Title", title.encode("ascii", "replace").decode("ascii"))
|
||||||
|
req.add_header("Priority", "high")
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
log(cfg, f"ntfy alert sent: HTTP {resp.status}")
|
||||||
|
except Exception as e:
|
||||||
|
log(cfg, f"ntfy push FAILED: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def smtp_login(cfg):
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
s = smtplib.SMTP_SSL(cfg["SMTP_HOST"], int(cfg.get("SMTP_PORT", 465)),
|
||||||
|
context=ctx, timeout=25)
|
||||||
|
try:
|
||||||
|
s.login(cfg["SMTP_USER"], cfg["SMTP_PASS"])
|
||||||
|
finally:
|
||||||
|
s.quit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def smtp_send_test(cfg):
|
||||||
|
msg = email.message.EmailMessage()
|
||||||
|
msg["From"] = cfg["SMTP_USER"]
|
||||||
|
msg["To"] = cfg["OPS_NOTIFY_EMAIL"]
|
||||||
|
msg["Subject"] = "[snolla-monitor] weekly SMTP send test OK"
|
||||||
|
msg.set_content(
|
||||||
|
"Automated weekly test send from snolla-smtp-monitor (vds-kzntsv). "
|
||||||
|
"If you see this, SMTP sending for snolla sites works.\n"
|
||||||
|
f"ts={datetime.datetime.now().astimezone().isoformat()}"
|
||||||
|
)
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
s = smtplib.SMTP_SSL(cfg["SMTP_HOST"], int(cfg.get("SMTP_PORT", 465)),
|
||||||
|
context=ctx, timeout=25)
|
||||||
|
try:
|
||||||
|
s.login(cfg["SMTP_USER"], cfg["SMTP_PASS"])
|
||||||
|
s.send_message(msg)
|
||||||
|
finally:
|
||||||
|
s.quit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def portainer_parity(cfg, expected_user, expected_pass):
|
||||||
|
"""Every env-driven snolla stack must carry the expected SMTP creds in its env."""
|
||||||
|
bad = []
|
||||||
|
# JWT auth (API key gave 401 historically — use admin password JWT)
|
||||||
|
auth_req = urllib.request.Request(
|
||||||
|
f"{cfg['PORTAINER_URL']}/api/auth",
|
||||||
|
data=json.dumps({"username": cfg["PORTAINER_USER"],
|
||||||
|
"password": cfg["PORTAINER_PASS"]}).encode(),
|
||||||
|
headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(auth_req, timeout=15) as r:
|
||||||
|
jwt = json.loads(r.read())["jwt"]
|
||||||
|
stacks_req = urllib.request.Request(f"{cfg['PORTAINER_URL']}/api/stacks",
|
||||||
|
headers={"Authorization": "Bearer " + jwt})
|
||||||
|
with urllib.request.urlopen(stacks_req, timeout=15) as r:
|
||||||
|
stacks = json.loads(r.read())
|
||||||
|
by_id = {s["Id"]: s for s in stacks}
|
||||||
|
for sid in cfg.get("PORTAINER_STACKS", DEFAULT_STACKS):
|
||||||
|
s = by_id.get(int(sid))
|
||||||
|
if s is None:
|
||||||
|
bad.append(f"stack {sid}: NOT FOUND")
|
||||||
|
continue
|
||||||
|
env = {e.get("name"): e.get("value") for e in s.get("Env", [])}
|
||||||
|
u, p = env.get("SMTP_USER"), env.get("SMTP_PASSWORD")
|
||||||
|
if u != expected_user or p != expected_pass:
|
||||||
|
bad.append(f"stack {sid} ({s.get('Name')}): SMTP env MISMATCH "
|
||||||
|
f"(user={u!r} pass_len={len(p) if p else 0})")
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = load_env(ENV_PATH)
|
||||||
|
problems = []
|
||||||
|
|
||||||
|
# 1. SMTP auth
|
||||||
|
try:
|
||||||
|
smtp_login(cfg)
|
||||||
|
log(cfg, "OK smtp-auth")
|
||||||
|
except Exception as e:
|
||||||
|
problems.append(f"SMTP auth FAILED: {e}")
|
||||||
|
|
||||||
|
# 3. weekly real send (Monday)
|
||||||
|
if datetime.date.today().weekday() == 0:
|
||||||
|
try:
|
||||||
|
smtp_send_test(cfg)
|
||||||
|
log(cfg, "OK weekly-send")
|
||||||
|
except Exception as e:
|
||||||
|
problems.append(f"weekly test send FAILED: {e}")
|
||||||
|
|
||||||
|
# 2. Portainer env parity
|
||||||
|
try:
|
||||||
|
bad = portainer_parity(cfg, cfg["SMTP_USER"], cfg["SMTP_PASS"])
|
||||||
|
if bad:
|
||||||
|
problems.append("parity: " + "; ".join(bad))
|
||||||
|
else:
|
||||||
|
log(cfg, "OK parity")
|
||||||
|
except Exception as e:
|
||||||
|
problems.append(f"parity check FAILED: {e}")
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
body = "\n".join(problems)
|
||||||
|
log(cfg, "ALERT: " + body)
|
||||||
|
ntfy_alert(cfg, "snolla SMTP monitor: FAIL", body)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -41,7 +41,7 @@ notify() {
|
|||||||
|
|
||||||
email_send() {
|
email_send() {
|
||||||
local subj="$1" body="$2"
|
local subj="$1" body="$2"
|
||||||
printf "From: noreply@snolla.com\nTo: %s\nSubject: %s\n\n%s\n" \
|
printf "From: e-16513832@yandex.ru\nTo: %s\nSubject: %s\n\n%s\n" \
|
||||||
"$EMAIL" "$subj" "$body" | msmtp "$EMAIL" || true
|
"$EMAIL" "$subj" "$body" | msmtp "$EMAIL" || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user