# Настраиваемые каналы уведомлений — план реализации > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Сделать каналы уведомлений настраиваемыми через конфиг и добавить Pachca и Email к существующему Telegram, выбирая канал по пути URL (`/telegram`, `/pachca`, `/email`). **Architecture:** Общая функция `ExtractRequest` читает входящий HTTP-запрос один раз в нейтральную `RequestData`. Интерфейс `Notifier` с тремя реализациями (telegram/pachca/email) форматирует данные под свой канал и отправляет через библиотеку `gitea.mediatoday.ru/mt/notify`. На старте из конфига собирается `map[string]Notifier`; хендлер маршрутизирует по первому сегменту пути. **Tech Stack:** Go 1.25+, `gitea.mediatoday.ru/mt/notify`, `gopkg.in/yaml.v3`, стандартные `net/http`, `net/mail`, `net/smtp`, `html`, `net/http/httptest`. --- ## Структура файлов - `go.mod` / `go.sum` — заменить зависимость `git.gm6.ru/icewind/notify` на `gitea.mediatoday.ru/mt/notify`. - `config.go` — **создать**: типы конфига и `loadConfig` (вынести из `main.go`). - `request.go` — **создать**: `RequestData`, `GetRemoteAddr`, `ExtractRequest`. - `notifier.go` — **создать**: интерфейс `Notifier` и три реализации + `BuildNotifiers`. - `main.go` — **изменить**: убрать вынесенное, собрать каналы, маршрутизировать по map. - `request_test.go` — **создать**: тесты `ExtractRequest` / `GetRemoteAddr`. - `notifier_test.go` — **создать**: тесты форматирования и маршрутизации хендлера через мок. - `config.yaml.default` — **изменить**: добавить секции pachca/email, исправить опечатку. - `handler.go` — **создать**: фабрика хендлера, принимающая `map[string]Notifier` (для тестируемости). Заметка по именованию: текущий тип называется `ConfigTelegraam` (опечатка) — переименовываем в `ConfigTelegram`. --- ## Task 1: Обновить зависимость notify на новый путь **Files:** - Modify: `go.mod` - Modify: `go.sum` - [ ] **Step 1: Заменить импорт во всех .go файлах** В `main.go` строка импорта уже `git.gm6.ru/icewind/notify`. Заменить на новый путь: ```go "gitea.mediatoday.ru/mt/notify" ``` (Имя пакета остаётся `notify`, поэтому остальной код не меняется на этом шаге.) - [ ] **Step 2: Обновить модуль и зачистить старую зависимость** Run: ```bash cd /home/soer/projects/http_logger go get gitea.mediatoday.ru/mt/notify@v0.0.0-20260405185738-6f5db00fcb34 go mod tidy ``` Expected: в `go.mod` появляется `gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34` (без `// indirect`), строка `git.gm6.ru/icewind/notify` исчезает. - [ ] **Step 3: Проверить сборку** Run: `go build ./...` Expected: успешная сборка без ошибок (код ещё прежний, только путь импорта новый). - [ ] **Step 4: Commit** ```bash git add go.mod go.sum main.go git commit -m "build: перейти на gitea.mediatoday.ru/mt/notify" ``` --- ## Task 2: Вынести конфиг в config.go с новыми секциями **Files:** - Create: `config.go` - Modify: `main.go` (удалить перенесённое) - [ ] **Step 1: Создать config.go** ```go package main import ( "os" "gopkg.in/yaml.v3" ) // Config — структура для чтения YAML-файла type Config struct { ListenAddresses []string `yaml:"listen_addresses"` LogDir string `yaml:"log_dir"` Telegram *ConfigTelegram `yaml:"telegram"` Pachca *ConfigPachca `yaml:"pachca"` Email *ConfigEmail `yaml:"email"` } type ConfigTelegram struct { Token string `yaml:"token"` GroupID int64 `yaml:"group_id"` DisableIPV6 *bool `yaml:"disable_ipv6"` // nil → true } type ConfigPachca struct { WebhookURL string `yaml:"webhook_url"` // входящий вебхук Pachca } type ConfigEmail struct { SMTPAddr string `yaml:"smtp_addr"` Username string `yaml:"username"` Password string `yaml:"password"` From string `yaml:"from"` To []string `yaml:"to"` Subject string `yaml:"subject"` // "" → "HTTP Logger" } // loadConfig читает YAML-конфиг из файла func loadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } return &cfg, nil } ``` - [ ] **Step 2: Удалить перенесённое из main.go** В `main.go` удалить: типы `Config`, `ConfigTelegraam`, функцию `loadConfig`, и импорты `os` и `gopkg.in/yaml.v3` (теперь не нужны в main.go). Оставить остальной код пока как есть — он будет переписан в Task 5. Импорт `yaml` и `os` останется неиспользуемым в main.go, поэтому временно сборка может ругаться — это нормально, исправится после удаления использования. Чтобы не ломать сборку между задачами, удаление импортов делается вместе с удалением типов в этом же шаге. - [ ] **Step 3: Проверить сборку** Run: `go build ./...` Expected: успешная сборка (main.go всё ещё использует только `cfg.Telegram`, типы теперь из config.go). - [ ] **Step 4: Commit** ```bash git add config.go main.go git commit -m "refactor: вынести конфиг в config.go, добавить секции pachca/email" ``` --- ## Task 3: Извлечение данных запроса (request.go) — TDD **Files:** - Create: `request.go` - Create: `request_test.go` - Modify: `main.go` (удалить `GetRemoteAddr` и часть `BuildMessage`) - [ ] **Step 1: Написать падающий тест** Создать `request_test.go`: ```go package main import ( "net/http" "net/http/httptest" "strings" "testing" ) func TestGetRemoteAddr(t *testing.T) { tests := []struct { name string set func(r *http.Request) want string }{ {"xff", func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2") }, "1.1.1.1"}, {"realip", func(r *http.Request) { r.Header.Set("X-Real-IP", "3.3.3.3") }, "3.3.3.3"}, {"remoteaddr", func(r *http.Request) { r.RemoteAddr = "4.4.4.4:5678" }, "4.4.4.4"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest("GET", "/telegram", nil) r.RemoteAddr = "" tt.set(r) if got := GetRemoteAddr(r); got != tt.want { t.Fatalf("got %q want %q", got, tt.want) } }) } } func TestExtractRequest(t *testing.T) { r := httptest.NewRequest("POST", "http://example.com/telegram?x=1", strings.NewReader("hello")) r.Header.Set("X-Real-IP", "9.9.9.9") data := ExtractRequest(r) if data.Method != "POST" { t.Errorf("method = %q", data.Method) } if data.RemoteAddr != "9.9.9.9" { t.Errorf("remoteaddr = %q", data.RemoteAddr) } if data.Body != "hello" { t.Errorf("body = %q", data.Body) } if data.URL != "/telegram?x=1" { t.Errorf("url = %q", data.URL) } } func TestExtractRequestBodyLimit(t *testing.T) { big := strings.Repeat("a", 2000) r := httptest.NewRequest("POST", "/telegram", strings.NewReader(big)) r.ContentLength = int64(len(big)) data := ExtractRequest(r) if data.Body != "too long" { t.Errorf("expected 'too long', got len %d", len(data.Body)) } } ``` - [ ] **Step 2: Запустить тест — убедиться, что падает** Run: `go test ./... -run TestExtractRequest -v` Expected: FAIL / build error — `GetRemoteAddr` и `ExtractRequest` ещё не существуют (после Task 5 они удалятся из main.go; на этом шаге их пока нет в request.go). - [ ] **Step 3: Создать request.go** ```go package main import ( "io" "net" "net/http" "time" ) // RequestData — извлечённые данные входящего запроса type RequestData struct { Time time.Time Method string Host string URL string RemoteAddr string Header http.Header Body string } func GetRemoteAddr(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { ips := splitFirst(xff) if ips != "" { return ips } } if realIP := r.Header.Get("X-Real-IP"); realIP != "" { return realIP } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func splitFirst(xff string) string { for i := 0; i < len(xff); i++ { if xff[i] == ',' { return trimSpace(xff[:i]) } } return trimSpace(xff) } func trimSpace(s string) string { for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { s = s[1:] } for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { s = s[:len(s)-1] } return s } // ExtractRequest читает запрос один раз и возвращает нейтральные данные. func ExtractRequest(r *http.Request) RequestData { var body []byte if r.ContentLength > 1024 { body = []byte("too long") } else { body, _ = io.ReadAll(r.Body) _ = r.Body.Close() } return RequestData{ Time: time.Now(), Method: r.Method, Host: r.Host, URL: r.URL.String(), RemoteAddr: GetRemoteAddr(r), Header: r.Header, Body: string(body), } } ``` Заметка: `splitFirst`/`trimSpace` дублируют `strings`-логику без лишнего импорта; допустимо просто использовать `strings.Split`/`strings.TrimSpace` — если предпочитаешь `strings`, замени тело `GetRemoteAddr` на исходный вариант из старого `main.go` и добавь `"strings"` в импорт. Главное — поведение из теста. - [ ] **Step 4: Запустить тесты — убедиться, что проходят** Run: `go test ./... -run TestExtractRequest -v && go test ./... -run TestGetRemoteAddr -v` Expected: PASS. (Если `GetRemoteAddr` всё ещё объявлен в main.go — будет ошибка дублирования; тогда сначала выполни Step 5.) - [ ] **Step 5: Удалить дубликаты из main.go** В `main.go` удалить функции `GetRemoteAddr` и `BuildMessage` целиком и неиспользуемые импорты (`io`, `net`, `time`, `fmt` — те, что больше не нужны). Логика `BuildMessage` (форматирование) переедет в notifier.go (Task 4). На этом шаге сборка main.go временно сломается, т.к. `handler` всё ещё вызывает `BuildMessage` — это будет починено в Task 5. Чтобы не коммитить несобираемое состояние, **объедини Step 5 с Task 5** при выполнении (см. примечание ниже). > Примечание для исполнителя: Task 3 и Task 5 затрагивают `main.go` совместно. Рекомендуется выполнять их подряд и коммитить после Task 5, либо закоммитить request.go + тесты отдельно (Step 1–4), а правку main.go сделать в Task 5. - [ ] **Step 6: Commit (только request.go и тесты)** ```bash git add request.go request_test.go git commit -m "feat: ExtractRequest — извлечение данных запроса с тестами" ``` --- ## Task 4: Интерфейс Notifier и реализации каналов — TDD **Files:** - Create: `notifier.go` - Create: `notifier_test.go` - [ ] **Step 1: Написать падающий тест форматирования** Создать `notifier_test.go`: ```go package main import ( "net/http" "strings" "testing" "time" ) func sampleData() RequestData { return RequestData{ Time: time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC), Method: "POST", Host: "example.com", URL: "/telegram", RemoteAddr: "1.2.3.4", Header: http.Header{"X-Test": []string{"v"}}, Body: "payload<>", } } func TestFormatMarkdown(t *testing.T) { out := formatMarkdown(sampleData()) if !strings.HasPrefix(out, "```") || !strings.HasSuffix(out, "```") { t.Fatalf("expected ``` wrapping, got: %q", out) } if !strings.Contains(out, "POST") || !strings.Contains(out, "1.2.3.4") { t.Errorf("missing fields: %q", out) } if !strings.Contains(out, "X-Test: v") { t.Errorf("missing header: %q", out) } } func TestFormatHTML(t *testing.T) { out := formatHTML(sampleData()) if !strings.Contains(out, "
") || !strings.Contains(out, "
") { t.Fatalf("expected
 wrapping, got: %q", out)
	}
	// тело экранируется
	if !strings.Contains(out, "payload<>") {
		t.Errorf("body not escaped: %q", out)
	}
	if strings.Contains(out, "payload<>") {
		t.Errorf("unescaped body present: %q", out)
	}
}
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `go test ./... -run TestFormat -v`
Expected: FAIL / build error — `formatMarkdown` и `formatHTML` не существуют.

- [ ] **Step 3: Создать notifier.go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html"
	"io"
	"net"
	"net/http"
	"net/mail"
	"net/smtp"
	"strings"
	"time"

	"gitea.mediatoday.ru/mt/notify"
)

// Notifier — канал отправки уведомлений
type Notifier interface {
	Send(data RequestData) error
}

// formatPlain собирает текстовое тело без обёртки.
func formatPlain(d RequestData) string {
	var b strings.Builder
	fmt.Fprintf(&b, "[%s] %s %s%s\n", d.Time.Format("2006-01-02T15:04:05Z07:00"), d.Method, d.Host, d.URL)
	fmt.Fprintf(&b, "RemoteAddr: %s\n", d.RemoteAddr)
	b.WriteString("Headers:\n")
	for name, values := range d.Header {
		for _, v := range values {
			fmt.Fprintf(&b, "  %s: %s\n", name, v)
		}
	}
	fmt.Fprintf(&b, "Body:\n%s\n", d.Body)
	b.WriteString("----\n")
	return b.String()
}

// formatMarkdown — обёртка в код-блок для Telegram/Pachca.
func formatMarkdown(d RequestData) string {
	return "```" + formatPlain(d) + "```"
}

// formatHTML — обёртка в 
 с экранированием для Email.
func formatHTML(d RequestData) string {
	return "
" + html.EscapeString(formatPlain(d)) + "
" } // --- Telegram --- type telegramNotifier struct { bot *notify.Bot chatID int64 } func (t *telegramNotifier) Send(d RequestData) error { _, err := t.bot.SendTextMessage(t.chatID, formatMarkdown(d)) return err } // --- Pachca (входящий вебхук) --- type pachcaNotifier struct { webhookURL string client *http.Client } func (p *pachcaNotifier) Send(d RequestData) error { payload, err := json.Marshal(map[string]string{"message": formatMarkdown(d)}) if err != nil { return err } resp, err := p.client.Post(p.webhookURL, "application/json", bytes.NewReader(payload)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("pachca webhook: HTTP %d: %s", resp.StatusCode, string(body)) } return nil } // --- Email --- type emailNotifier struct { auth notify.SmtpAuth from mail.Address to []mail.Address subject string } func (e *emailNotifier) Send(d RequestData) error { return notify.SendEmailHTML(e.auth, formatHTML(d), e.subject, e.from, e.to...) } // BuildNotifiers собирает map каналов из конфига. Добавляет только заданные секции. func BuildNotifiers(cfg *Config) (map[string]Notifier, error) { notifiers := map[string]Notifier{} if cfg.Telegram != nil { disableIPV6 := true if cfg.Telegram.DisableIPV6 != nil { disableIPV6 = *cfg.Telegram.DisableIPV6 } bot, err := notify.NewTelegram(cfg.Telegram.Token, disableIPV6) if err != nil { return nil, fmt.Errorf("telegram: %w", err) } notifiers["telegram"] = &telegramNotifier{bot: bot, chatID: cfg.Telegram.GroupID} } if cfg.Pachca != nil { if cfg.Pachca.WebhookURL == "" { return nil, fmt.Errorf("pachca: не задан webhook_url") } notifiers["pachca"] = &pachcaNotifier{ webhookURL: cfg.Pachca.WebhookURL, client: &http.Client{Timeout: 30 * time.Second}, } } if cfg.Email != nil { host, _, err := splitHostPortLenient(cfg.Email.SMTPAddr) if err != nil { return nil, fmt.Errorf("email smtp_addr: %w", err) } from, err := mail.ParseAddress(cfg.Email.From) if err != nil { return nil, fmt.Errorf("email from: %w", err) } var to []mail.Address for _, addr := range cfg.Email.To { a, err := mail.ParseAddress(addr) if err != nil { return nil, fmt.Errorf("email to %q: %w", addr, err) } to = append(to, *a) } if len(to) == 0 { return nil, fmt.Errorf("email: не задан ни один получатель (to)") } subject := cfg.Email.Subject if subject == "" { subject = "HTTP Logger" } notifiers["email"] = &emailNotifier{ auth: notify.SmtpAuth{ Addr: cfg.Email.SMTPAddr, Auth: smtp.PlainAuth("", cfg.Email.Username, cfg.Email.Password, host), }, from: *from, to: to, subject: subject, } } return notifiers, nil } ``` Добавить хелпер извлечения хоста (в notifier.go, под `BuildNotifiers`): ```go func splitHostPortLenient(addr string) (host, port string, err error) { return net.SplitHostPort(addr) } ``` > Примечание: `splitHostPortLenient` — тонкая обёртка над `net.SplitHostPort`; можно вызвать `net.SplitHostPort` напрямую и убрать хелпер. Оставлено для явности. `"net"` уже включён в блок импортов выше. - [ ] **Step 4: Запустить тесты — убедиться, что проходят** Run: `go test ./... -run TestFormat -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add notifier.go notifier_test.go git commit -m "feat: интерфейс Notifier + каналы telegram/pachca/email" ``` --- ## Task 5: Хендлер с маршрутизацией по map (handler.go) — TDD **Files:** - Create: `handler.go` - Create/Modify: `notifier_test.go` (добавить тесты хендлера) - Modify: `main.go` - [ ] **Step 1: Написать падающий тест хендлера** Добавить в `notifier_test.go`: ```go import ( "net/http/httptest" ) type mockNotifier struct { called bool err error } func (m *mockNotifier) Send(d RequestData) error { m.called = true return m.err } func TestHandlerRouting(t *testing.T) { tg := &mockNotifier{} h := makeHandler(map[string]Notifier{"telegram": tg}) // известный канал → 200, Send вызван rec := httptest.NewRecorder() h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x"))) if rec.Code != 200 || !tg.called { t.Fatalf("telegram: code=%d called=%v", rec.Code, tg.called) } // неизвестный канал → 404 rec = httptest.NewRecorder() h(rec, httptest.NewRequest("POST", "/unknown", strings.NewReader("x"))) if rec.Code != 404 { t.Fatalf("unknown: code=%d", rec.Code) } // пустой путь → 404 rec = httptest.NewRecorder() h(rec, httptest.NewRequest("POST", "/", nil)) if rec.Code != 404 { t.Fatalf("empty: code=%d", rec.Code) } } func TestHandlerSendError(t *testing.T) { failing := &mockNotifier{err: errSend} h := makeHandler(map[string]Notifier{"telegram": failing}) rec := httptest.NewRecorder() h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x"))) if rec.Code != 502 { t.Fatalf("expected 502, got %d", rec.Code) } } ``` Добавить в начало `notifier_test.go` объявление ошибки рядом с другими переменными: ```go import "errors" var errSend = errors.New("send failed") ``` - [ ] **Step 2: Запустить — убедиться, что падает** Run: `go test ./... -run TestHandler -v` Expected: FAIL / build error — `makeHandler` не существует. - [ ] **Step 3: Создать handler.go** ```go package main import ( "log" "net/http" "strings" ) // makeHandler возвращает http-хендлер, маршрутизирующий по первому сегменту пути. func makeHandler(notifiers map[string]Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { paths := strings.Split(r.URL.Path, "/") if len(paths) < 2 || paths[1] == "" { w.WriteHeader(http.StatusNotFound) return } n, ok := notifiers[paths[1]] if !ok { w.WriteHeader(http.StatusNotFound) return } data := ExtractRequest(r) if err := n.Send(data); err != nil { log.Println(err) w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte(err.Error())) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } } ``` - [ ] **Step 4: Переписать main.go** `main.go` целиком: ```go package main import ( "log" "net/http" ) func main() { log.Println("Запуск приложения") cfg, err := loadConfig("config.yaml") if err != nil { log.Fatalf("Ошибка загрузки конфига: %v", err) } notifiers, err := BuildNotifiers(cfg) if err != nil { log.Fatalf("Ошибка инициализации каналов: %v", err) } if len(notifiers) == 0 { log.Fatal("не сконфигурирован ни один канал уведомлений") } http.HandleFunc("/", makeHandler(notifiers)) for _, addr := range cfg.ListenAddresses { addr := addr go func() { log.Printf("Слушаю %s ...", addr) if err := http.ListenAndServe(addr, nil); err != nil { log.Printf("Ошибка на %s: %v", addr, err) } }() } select {} } ``` - [ ] **Step 5: Запустить все тесты** Run: `go test ./...` Expected: PASS по всем тестам (request, format, handler). - [ ] **Step 6: Проверить сборку бинаря** Run: `go build ./... && ./build.sh` Expected: успешная сборка, бинарь в `bin/http_logger`. - [ ] **Step 7: Commit** ```bash git add handler.go main.go notifier_test.go git commit -m "feat: маршрутизация каналов по пути URL через map[string]Notifier" ``` --- ## Task 6: Обновить config.yaml.default **Files:** - Modify: `config.yaml.default` - [ ] **Step 1: Переписать config.yaml.default** ```yaml listen_addresses: - ":8080" - "127.0.0.1:9090" log_dir: "logs" # Любую секцию можно опустить — тогда соответствующий путь вернёт 404. telegram: token: "*****" group_id: -10012345 disable_ipv6: true # опционально, по умолчанию true pachca: webhook_url: "https://api.pachca.com/webhooks/XXXX" # входящий вебхук email: smtp_addr: "smtp.example.com:587" username: "user@example.com" password: "*****" from: "logger@example.com" to: - "alert@example.com" subject: "HTTP Logger" # опционально ``` - [ ] **Step 2: Проверить, что пример валиден** Run: ```bash cp config.yaml.default /tmp/cfg_check.yaml go run . 2>&1 | head -3 || true ``` Примечание: запуск `go run .` попытается прочитать `config.yaml` (не `.default`). Достаточно убедиться, что YAML парсится — можно временно скопировать в `config.yaml`, запустить и сразу остановить (Ctrl+C), либо положиться на то, что структура совпадает с типами из config.go. Не коммитить реальный `config.yaml` (он в `.gitignore`). - [ ] **Step 3: Commit** ```bash git add config.yaml.default git commit -m "docs: пример конфига с секциями pachca и email" ``` --- ## Task 7: Обновить README **Files:** - Modify: `README.md` - [ ] **Step 1: Записать README.md** ```markdown # http_logger HTTP-логгер: принимает входящие HTTP-запросы и пересылает их в настроенный канал уведомлений. Канал выбирается первым сегментом пути: - `POST /telegram` → Telegram - `POST /pachca` → Pachca (Пачка) - `POST /email` → Email Каналы настраиваются в `config.yaml` (пример — `config.yaml.default`). Несконфигурированный путь возвращает `404`, ошибка отправки — `502`. ## Сборка ```bash ./build.sh ``` ## Запуск ```bash cp config.yaml.default config.yaml # отредактировать токены ./bin/http_logger ``` ``` - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "docs: описать настраиваемые каналы в README" ``` --- ## Self-Review (выполнено при написании плана) **Покрытие спеки:** - Путь = канал → Task 5 (makeHandler). - Одна секция на тип → Task 2 (Config). - Форматирование под канал → Task 4 (formatMarkdown/formatHTML). - Канал не сконфигурирован → 404 → Task 5 (тест TestHandlerRouting). - Обновление модуля notify → Task 1. - ExtractRequest читает тело один раз, лимит 1024 → Task 3. - Email: host из smtp_addr, PlainAuth, список получателей, subject default → Task 4 (BuildNotifiers). - Тесты: ExtractRequest/GetRemoteAddr (Task 3), форматирование (Task 4), маршрутизация через мок (Task 5). - Конфиг-пример + README → Task 6, 7. **Согласованность типов:** `Notifier.Send(RequestData) error`, `RequestData`, `BuildNotifiers`, `makeHandler`, `formatMarkdown`/`formatHTML`/`formatPlain` — имена совпадают во всех задачах. **Замечание по порядку:** Task 3 и Task 5 совместно правят `main.go`. Исполнителю рекомендовано коммитить request.go/тесты отдельно (Task 3), а правку main.go завершить в Task 5 — между этими задачами сборка `main.go` может быть временно сломана, это отражено в примечаниях.