29 KiB
Executable File
Telegram Notification Provider — Implementation Plan
Date: 2026-07-10 Author: Planning Agent Confidence Score: 92% (High — existing patterns well-established, Telegram Bot API straightforward)
1. Introduction
Objective
Add Telegram as a first-class notification provider in Charon, following the established architecture used by Discord, Gotify, Email, and generic Webhook providers.
Goals
- Users can configure a Telegram bot token and chat ID to receive notifications via Telegram
- All existing notification event types (proxy hosts, certs, uptime, security events) work with Telegram
- JSON template engine (minimal/detailed/custom) works with Telegram
- Feature flag allows enabling/disabling Telegram dispatch independently
- Token is treated as a secret (write-only, never exposed in API responses)
- Full test coverage: Go unit tests, Vitest frontend tests, Playwright E2E tests
Telegram Bot API Overview
Telegram bots send messages via:
POST https://api.telegram.org/bot<BOT_TOKEN>/sendMessage
Content-Type: application/json
{
"chat_id": "<CHAT_ID>",
"text": "Hello world",
"parse_mode": "HTML" // optional: "HTML" or "MarkdownV2"
}
Key design decisions:
- Token storage: The bot token is stored in
NotificationProvider.Token(json:"-", encrypted at rest) — never in the URL field. This mirrors the Gotify pattern where secrets are separated from endpoints. - URL field: Stores only the
chat_id(e.g.,987654321). At dispatch time, the full API URL is constructed dynamically:https://api.telegram.org/bot+ decryptedToken +/sendMessage. Thechat_idis passed in the POST body alongside the message text. This prevents token leakage via API responses since URL isjson:"url". - SSRF mitigation: Before dispatching, validate that the constructed URL hostname is exactly
api.telegram.org. This prevents SSRF if stored data is tampered with. - Dispatch path: Uses
sendJSONPayload→httpWrapper.Send()(same as Gotify), since both are token-based JSON POST providers - No schema migration needed: The existing
NotificationProvidermodel accommodates Telegram without changes
Supervisor Review Note: The original design embedded the bot token in the URL field (
https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>). This was rejected because the URL field isjson:"url"— exposed in every API response. The token MUST only reside in theTokenfield (json:"-").
2. Research Findings
Existing Architecture
The notification system follows a provider-based architecture:
| Layer | File | Role |
|---|---|---|
| Feature flags | backend/internal/notifications/feature_flags.go |
Flag constants (FlagXxxServiceEnabled) |
| Feature flag handler | backend/internal/api/handlers/feature_flags_handler.go |
DB-backed flags with defaults |
| Router | backend/internal/notifications/router.go |
ShouldUseNotify() per-type dispatch |
| Service | backend/internal/services/notification_service.go |
Core dispatch: isSupportedNotificationProviderType(), isDispatchEnabled(), supportsJSONTemplates(), sendJSONPayload(), TestProvider() |
| Handlers | backend/internal/api/handlers/notification_provider_handler.go |
CRUD + type validation + token preservation |
| Model | backend/internal/models/notification_provider.go |
GORM model with Token (json:"-"), HasToken |
| Frontend API | frontend/src/api/notifications.ts |
SUPPORTED_NOTIFICATION_PROVIDER_TYPES, sanitization |
| Frontend UI | frontend/src/pages/Notifications.tsx |
Provider form with conditional fields per type |
| i18n | frontend/src/locales/en/translation.json |
Label strings |
| E2E fixtures | tests/fixtures/notifications.ts |
telegramProvider already defined |
Existing Provider Addition Points (Switch Statements / Type Checks)
Every location that checks provider types is listed below — all require a "telegram" case:
| # | File | Function/Line | Current Logic |
|---|---|---|---|
| 1 | feature_flags.go |
Constants | Missing FlagTelegramServiceEnabled |
| 2 | feature_flags_handler.go |
defaultFlags + defaultFlagValues |
Missing telegram entry |
| 3 | router.go |
ShouldUseNotify() switch |
Missing case "telegram" |
| 4 | notification_service.go |
isSupportedNotificationProviderType() |
case "discord", "email", "gotify", "webhook" |
| 5 | notification_service.go |
isDispatchEnabled() |
switch with per-type flag checks |
| 6 | notification_service.go |
supportsJSONTemplates() |
case "webhook", "discord", "gotify", "slack", "generic" |
| 7 | notification_service.go |
sendJSONPayload() — service-specific validation |
Missing case "telegram" for payload validation |
| 8 | notification_service.go |
sendJSONPayload() — dispatch branch |
Gotify/webhook use httpWrapper.Send(); others use ValidateExternalURL + SafeHTTPClient |
| 9 | notification_provider_handler.go |
Create() type guard |
providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" |
| 10 | notification_provider_handler.go |
Update() type guard |
Same pattern as Create |
| 11 | notification_provider_handler.go |
Update() token preservation |
if providerType == "gotify" && strings.TrimSpace(req.Token) == "" |
| 12 | notifications.ts |
SUPPORTED_NOTIFICATION_PROVIDER_TYPES |
['discord', 'gotify', 'webhook', 'email'] |
| 13 | notifications.ts |
sanitizeProviderForWriteAction() |
Token handling only for type !== 'gotify' |
| 14 | Notifications.tsx |
Type <select> options |
discord/gotify/webhook/email |
| 15 | Notifications.tsx |
normalizeProviderPayloadForSubmit() |
Token mapping only for type === 'gotify' |
| 16 | Notifications.tsx |
Conditional form fields | isGotify shows token input |
Test Files Requiring Updates
| File | Current Behavior | Required Change |
|---|---|---|
notification_service_test.go (~L1819) |
TestTestProvider_NotifyOnlyRejectsUnsupportedProvider tests "telegram" as unsupported |
Change: telegram is now supported |
notification_service_json_test.go |
Discord/Slack/Gotify/Webhook JSON tests | Add telegram payload validation tests |
notification_provider_handler_test.go |
CRUD tests with supported types | Add telegram to supported type lists |
enhanced_security_notification_service_test.go (~L139) |
Type: "telegram" marked // Should be filtered |
Change: telegram is now valid |
frontend/src/api/notifications.test.ts |
Rejects "telegram" as unsupported |
Accept telegram, add CRUD tests |
frontend/src/api/__tests__/notifications.test.ts |
Same rejection | Same fix |
tests/settings/notifications.spec.ts |
CRUD E2E for discord/gotify/webhook/email | Add telegram scenarios |
E2E Fixture Already Defined
// tests/fixtures/notifications.ts
// NOTE: Fixture must be updated — URL should contain only the chat_id, token goes in the token field
export const telegramProvider: NotificationProviderConfig = {
name: generateProviderName('telegram'),
type: 'telegram',
url: '987654321', // chat_id only — bot token is stored in the Token field
token: 'bot123456789:ABCdefGHIjklMNOpqrSTUvwxYZ', // stored encrypted, never in API responses
enabled: true,
notify_proxy_hosts: true,
notify_certs: true,
notify_uptime: true,
};
3. Technical Specifications
3.1 Backend — Feature Flags
File: backend/internal/notifications/feature_flags.go
Add constant:
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
File: backend/internal/api/handlers/feature_flags_handler.go
Add to defaultFlags slice:
notifications.FlagTelegramServiceEnabled,
Add to defaultFlagValues map:
notifications.FlagTelegramServiceEnabled: true,
Note: Telegram is enabled by default once the provider is toggled on in the UI, matching Gotify/Webhook behavior. The feature flag exists as an admin-level kill switch, not a setup gate.
3.2 Backend — Router
File: backend/internal/notifications/router.go
Add to ShouldUseNotify() switch:
case "telegram":
return flags[FlagTelegramServiceEnabled]
3.3 Backend — Notification Service
File: backend/internal/services/notification_service.go
isSupportedNotificationProviderType()
case "discord", "email", "gotify", "webhook", "telegram":
return true
isDispatchEnabled()
case "telegram":
return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true)
Both defaultFlagValues (initial DB seed) and the isDispatchEnabled() fallback are true — Telegram is enabled by default once the provider is created in the UI. This matches Gotify/Webhook behavior (enabled-by-default, admin kill-switch via feature flag).
supportsJSONTemplates()
case "webhook", "discord", "gotify", "slack", "generic", "telegram":
return true
sendJSONPayload() — Service-Specific Validation
Add after the case "gotify": block:
case "telegram":
// Telegram requires 'text' field for the message body
if _, hasText := jsonPayload["text"]; !hasText {
// Auto-map 'message' to 'text' if present (template compatibility)
if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
jsonPayload["text"] = messageValue
normalizedBody, marshalErr := json.Marshal(jsonPayload)
if marshalErr != nil {
return fmt.Errorf("failed to normalize telegram payload: %w", marshalErr)
}
body.Reset()
if _, writeErr := body.Write(normalizedBody); writeErr != nil {
return fmt.Errorf("failed to write normalized telegram payload: %w", writeErr)
}
} else {
return fmt.Errorf("telegram payload requires 'text' field")
}
}
This auto-mapping mirrors the Discord pattern (message → content) so that the built-in minimal and detailed templates (which use "message" as a field) work out of the box with Telegram.
sendJSONPayload() — Dispatch Branch
Add "telegram" to the httpWrapper.Send() dispatch branch alongside gotify/webhook:
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" {
For telegram, the dispatch URL must be constructed at send time from the stored token and chat_id:
case "telegram":
// Construct the API URL dynamically — token is NEVER stored in the URL field
decryptedToken := provider.Token // already decrypted by service layer
dispatchURL = "https://api.telegram.org/bot" + decryptedToken + "/sendMessage"
// SSRF mitigation: validate hostname before dispatch
parsedURL, err := url.Parse(dispatchURL)
if err != nil || parsedURL.Hostname() != "api.telegram.org" {
return fmt.Errorf("telegram dispatch URL validation failed: invalid hostname")
}
// Inject chat_id into the JSON payload body (URL field stores the chat_id)
jsonPayload["chat_id"] = provider.URL
// Re-marshal the payload with chat_id included
updatedBody, marshalErr := json.Marshal(jsonPayload)
if marshalErr != nil {
return fmt.Errorf("failed to marshal telegram payload with chat_id: %w", marshalErr)
}
body.Reset()
body.Write(updatedBody)
The X-Gotify-Key header is only set when providerType == "gotify" — no header changes needed for telegram.
TestProvider() — Telegram-Specific Error Message
When testing a Telegram provider and the API returns HTTP 401 or 403, return a specific error message:
case "telegram":
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("provider rejected authentication. Verify your Telegram Bot Token")
}
This gives users actionable guidance instead of a generic HTTP status error.
3.4 Backend — Handler Layer
File: backend/internal/api/handlers/notification_provider_handler.go
Create() Type Guard
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
Update() Type Guard
Same change as Create:
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
Update() Token Preservation
Telegram bot tokens should be preserved on update when the user omits them (same UX as Gotify):
if (providerType == "gotify" || providerType == "telegram") && strings.TrimSpace(req.Token) == "" {
req.Token = existing.Token
}
3.5 Backend — Model (No Changes)
The NotificationProvider model already has:
Token stringwithjson:"-"(write-only, never exposed)HasToken boolwithgorm:"-"(computed field for frontend)URL stringfor the endpointServiceConfig stringfor extra JSON config (available forparse_modeif needed)
No schema migration is required.
3.6 Frontend — API Client
File: frontend/src/api/notifications.ts
Supported Types Array
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram'] as const;
sanitizeProviderForWriteAction()
Minimal diff only. Change only the type guard condition from:
if (type !== 'gotify') {
to:
if (type !== 'gotify' && type !== 'telegram') {
The surrounding normalization logic (token stripping, payload return) MUST remain untouched. No other lines in this function change.
sanitizeProviderForReadLikeAction()
No changes — already calls sanitizeProviderForWriteAction() then strips token.
3.7 Frontend — Notifications Page
File: frontend/src/pages/Notifications.tsx
Type Select Options
Add after the email option:
<option value="telegram">Telegram</option>
Computed Flags
const isTelegram = type === 'telegram';
normalizeProviderPayloadForSubmit()
Add telegram branch alongside gotify:
if (type === 'gotify' || type === 'telegram') {
const normalizedToken = typeof payload.gotify_token === 'string' ? payload.gotify_token.trim() : '';
if (normalizedToken.length > 0) {
payload.token = normalizedToken;
} else {
delete payload.token;
}
} else {
delete payload.token;
}
Note: Reuses the gotify_token form field for both Gotify and Telegram since both need a token input. This minimizes UI changes. The field label changes based on provider type.
Token Input Field
Expand the conditional from {isGotify && ( to {(isGotify || isTelegram) && (:
{(isGotify || isTelegram) && (
<div>
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
</label>
<input
id="provider-gotify-token"
type="password"
autoComplete="new-password"
{...register('gotify_token')}
data-testid="provider-gotify-token"
placeholder={initialData?.has_token
? t('notificationProviders.gotifyTokenKeepPlaceholder')
: isTelegram
? t('notificationProviders.telegramBotTokenPlaceholder')
: t('notificationProviders.gotifyTokenPlaceholder')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
aria-describedby={initialData?.has_token ? 'gotify-token-stored-hint' : undefined}
/>
{initialData?.has_token && (
<p id="gotify-token-stored-hint" data-testid="gotify-token-stored-indicator" className="text-xs text-green-600 dark:text-green-400 mt-1">
{t('notificationProviders.gotifyTokenStored')}
</p>
)}
<p className="text-xs text-gray-500 mt-1">{t('notificationProviders.gotifyTokenWriteOnlyHint')}</p>
</div>
)}
URL Field Placeholder
For Telegram, the URL field stores the chat_id (not a full URL). Update the placeholder and label accordingly:
placeholder={
isEmail ? 'user@example.com, admin@example.com'
: type === 'discord' ? 'https://discord.com/api/webhooks/...'
: type === 'gotify' ? 'https://gotify.example.com/message'
: isTelegram ? '987654321'
: 'https://example.com/webhook'
}
Update the label for the URL field when type is telegram:
label={isTelegram ? t('notificationProviders.telegramChatId') : t('notificationProviders.url')}
Clear Token on Type Change
Update the existing useEffect that clears gotify_token:
useEffect(() => {
if (type !== 'gotify' && type !== 'telegram') {
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
}
}, [type, setValue]);
3.8 Frontend — i18n Strings
File: frontend/src/locales/en/translation.json
Add to the notificationProviders section:
"telegram": "Telegram",
"telegramBotToken": "Bot Token",
"telegramBotTokenPlaceholder": "Enter your Telegram Bot Token",
"telegramChatId": "Chat ID",
"telegramChatIdPlaceholder": "987654321",
"telegramChatIdHelp": "Your Telegram chat, group, or channel ID. The bot token is stored securely and separately."
3.9 API Contract (No Changes)
The existing REST endpoints remain unchanged:
| Method | Endpoint | Notes |
|---|---|---|
GET |
/api/notification-providers |
Returns all providers (token stripped) |
POST |
/api/notification-providers |
Create — now accepts type: "telegram" |
PUT |
/api/notification-providers/:id |
Update — token preserved if omitted |
DELETE |
/api/notification-providers/:id |
Delete — no type-specific logic |
POST |
/api/notification-providers/test |
Test — routes through sendJSONPayload |
Request/response schemas are unchanged. The type field now accepts "telegram" in addition to existing values.
4. Implementation Plan
Phase 1: Playwright E2E Tests (Test-First)
Rationale: Per project conventions — write feature behaviour tests first.
New file: tests/settings/telegram-notification-provider.spec.ts
Modeled after tests/settings/email-notification-provider.spec.ts.
Test scenarios:
- Create a Telegram provider (name, chat_id in URL field, bot token in token field, enable events)
- Verify provider appears in the list
- Edit the Telegram provider (change name, verify token preservation)
- Test the Telegram provider (mock API returns 200)
- Delete the Telegram provider
- Negative security test: Verify
GET /api/notification-providersdoes NOT expose the bot token in any response field - Negative security test: Verify bot token is NOT present in the URL field of the API response
Update file: tests/settings/notifications-payload.spec.ts
Add telegram to the payload matrix test scenarios.
E2E fixtures: Update telegramProvider in tests/fixtures/notifications.ts — URL must contain only chat_id, token goes in the token field (see Research Findings section for updated fixture).
Phase 2: Backend Implementation
2A — Feature Flags (3 files)
| File | Change |
|---|---|
backend/internal/notifications/feature_flags.go |
Add FlagTelegramServiceEnabled constant |
backend/internal/api/handlers/feature_flags_handler.go |
Add to defaultFlags + defaultFlagValues |
backend/internal/notifications/router.go |
Add case "telegram" to ShouldUseNotify() |
2B — Service Layer (1 file, 4 function changes)
| File | Function | Change |
|---|---|---|
notification_service.go |
isSupportedNotificationProviderType() |
Add "telegram" to case |
notification_service.go |
isDispatchEnabled() |
Add case "telegram" with flag check |
notification_service.go |
supportsJSONTemplates() |
Add "telegram" to case |
notification_service.go |
sendJSONPayload() |
Add telegram validation + dispatch branch |
2C — Handler Layer (1 file, 3 locations)
| File | Location | Change |
|---|---|---|
notification_provider_handler.go |
Create() type guard |
Add && providerType != "telegram" |
notification_provider_handler.go |
Update() type guard |
Same |
notification_provider_handler.go |
Update() token preservation |
Add ` |
Phase 3: Frontend Implementation
3A — API Client (1 file)
| File | Change |
|---|---|
frontend/src/api/notifications.ts |
Add 'telegram' to SUPPORTED_NOTIFICATION_PROVIDER_TYPES, update token sanitization logic |
3B — Notifications Page (1 file)
| File | Change |
|---|---|
frontend/src/pages/Notifications.tsx |
Add telegram to type select, token field conditional, URL placeholder, normalizeProviderPayloadForSubmit(), type-change useEffect |
3C — Localization (1 file)
| File | Change |
|---|---|
frontend/src/locales/en/translation.json |
Add telegram-specific label strings |
Phase 4: Backend Tests
| Test File | Changes |
|---|---|
notification_service_test.go |
Update "rejects unsupported provider" test (remove telegram from unsupported list). Add telegram dispatch/integration tests. |
notification_service_json_test.go |
Add TestSendJSONPayload_Telegram_* tests: valid payload, missing text with message auto-map, missing both text and message, dispatch via httpWrapper, SSRF hostname validation, 401/403 error message |
notification_provider_handler_test.go |
Add telegram to Create/Update happy path tests, token preservation test. Add negative test: verify GET response does not contain bot token in URL field or response body |
enhanced_security_notification_service_test.go |
Change telegram from "filtered" to "valid provider" in security dispatch tests |
| Router test (if exists) | Add telegram to ShouldUseNotify() tests |
Phase 5: Frontend Tests
| Test File | Changes |
|---|---|
frontend/src/api/notifications.test.ts |
Remove telegram rejection test, add telegram CRUD sanitization tests |
frontend/src/api/__tests__/notifications.test.ts |
Same changes (duplicate test location) |
frontend/src/pages/Notifications.test.tsx |
Add telegram form rendering tests (token field visibility, placeholder text) |
Phase 6: Integration, Documentation & Deployment
- Verify E2E tests pass with Docker container
- Update
docs/features.mdwith Telegram provider mention - No
ARCHITECTURE.mdchanges needed (same provider pattern) - No database migration needed
5. Acceptance Criteria
EARS Requirements
| ID | Requirement |
|---|---|
| T-01 | WHEN a user creates a notification provider with type "telegram", THE SYSTEM SHALL accept the provider and store it in the database |
| T-02 | WHEN a user provides a bot token for a Telegram provider, THE SYSTEM SHALL store it securely and never expose it in API responses |
| T-03 | WHEN a Telegram provider is enabled and a notification event fires, THE SYSTEM SHALL construct the Telegram API URL dynamically from the stored token (https://api.telegram.org/bot + token + /sendMessage), inject chat_id from the URL field into the POST body, and send the rendered template payload |
| T-04 | WHEN the rendered JSON payload contains a "message" field but not a "text" field, THE SYSTEM SHALL auto-map "message" to "text" for Telegram compatibility |
| T-05 | WHEN the Telegram feature flag is disabled, THE SYSTEM SHALL skip dispatch for all Telegram providers |
| T-06 | WHEN a user updates a Telegram provider without providing a token, THE SYSTEM SHALL preserve the existing stored token |
| T-07 | WHEN a user tests a Telegram provider, THE SYSTEM SHALL send a test notification through the standard sendJSONPayload path |
| T-08 | WHEN the frontend renders the provider form with type "telegram", THE SYSTEM SHALL display a bot token input field and a chat_id input field (with appropriate placeholder) |
| T-09 | WHEN dispatching a Telegram notification, THE SYSTEM SHALL validate that the constructed URL hostname is exactly api.telegram.org before sending (SSRF mitigation) |
| T-10 | WHEN a Telegram test request receives HTTP 401 or 403, THE SYSTEM SHALL return the error message "Provider rejected authentication. Verify your Telegram Bot Token" |
| T-11 | WHEN the API returns notification providers via GET, THE SYSTEM SHALL NOT include the bot token in the URL field or any other exposed response field |
Definition of Done
- All 16 code touchpoints updated (see section 2 table)
- E2E Playwright tests pass for Telegram CRUD + test send
- Backend unit tests cover: type registration, dispatch routing, payload validation (text field), token preservation, feature flag gating
- Frontend unit tests cover: type array acceptance, sanitization, form rendering
go test ./...passesnpm testpassesnpx playwright test --project=firefoxpassesmake lint-fastpasses (staticcheck)- Coverage threshold maintained (85%+)
- GORM security scan passes (no model changes, but verify)
- Token never appears in API responses, logs, or frontend state
- Negative security tests pass (bot token not in GET response body or URL field)
- SSRF hostname validation test passes (only
api.telegram.orgallowed) - Telegram 401/403 returns specific auth error message
6. Commit Slicing Strategy
Decision: 2 PRs
Trigger reasons: Changes span backend + frontend + E2E tests with independent functionality per layer. Splitting improves review quality and rollback safety.
PR-1: Backend — Telegram Provider Support
Scope: Feature flags, service layer, handler layer, all Go unit tests
Files changed:
backend/internal/notifications/feature_flags.gobackend/internal/api/handlers/feature_flags_handler.gobackend/internal/notifications/router.gobackend/internal/services/notification_service.gobackend/internal/api/handlers/notification_provider_handler.gobackend/internal/services/notification_service_test.gobackend/internal/services/notification_service_json_test.gobackend/internal/api/handlers/notification_provider_handler_test.gobackend/internal/services/enhanced_security_notification_service_test.go
Dependencies: None (self-contained backend change)
Validation gates:
go test ./...passesmake lint-fastpasses- Coverage ≥ 85%
- GORM security scan passes
Rollback: Revert PR — no DB migration to undo.
PR-2: Frontend + E2E — Telegram Provider UI
Scope: Frontend API client, Notifications page, i18n strings, frontend unit tests, Playwright E2E tests
Files changed:
frontend/src/api/notifications.tsfrontend/src/pages/Notifications.tsxfrontend/src/locales/en/translation.jsonfrontend/src/api/notifications.test.tsfrontend/src/api/__tests__/notifications.test.tsfrontend/src/pages/Notifications.test.tsxtests/settings/telegram-notification-provider.spec.ts(new)tests/settings/notifications-payload.spec.ts
Dependencies: PR-1 must be merged first (backend must accept type: "telegram")
Validation gates:
npm testpassesnpm run type-checkpassesnpx playwright test --project=firefoxpasses- Coverage ≥ 85%
Rollback: Revert PR — frontend-only, no cascading effects.
7. Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Telegram API rate limiting | Low | Medium | Use existing retry/timeout patterns from httpWrapper |
| Bot token exposure in responses/logs | Low | Critical | Token stored ONLY in Token field (json:"-"), never in URL field. URL field contains only chat_id. Negative security tests verify this invariant. |
| Template auto-mapping edge cases | Low | Low | Test with all three template types (minimal, detailed, custom) |
| URL validation rejects chat_id format | Low | Low | URL field now stores a chat_id string (not a full URL). Validation may need adjustment to accept non-URL values for telegram type. |
| SSRF via tampered stored data | Low | High | Dispatch-time validation ensures hostname is exactly api.telegram.org. Dedicated test covers this. |
| E2E test flakiness with mocked API | Low | Low | Existing route-mocking patterns are stable |
8. Complexity Estimates
| Component | Estimate | Notes |
|---|---|---|
| Backend feature flags | S | 3 files, ~5 lines each |
| Backend service layer | M | 4 function changes + telegram validation block |
| Backend handler layer | S | 3 string-level changes |
| Frontend API client | S | 2 lines + sanitization tweak |
| Frontend UI | M | Template conditional, placeholder, useEffect updates |
| Frontend i18n | S | 4 strings |
| Backend tests | L | Multiple test files, new test functions, update existing assertions |
| Frontend tests | M | Update rejection tests, add rendering tests |
| E2E tests | M | New spec file modeled on existing email spec |
| Total | M-L | ~2-3 days of focused implementation |