- Updated the notification provider types to include 'slack'. - Modified API tests to handle 'slack' as a valid provider type. - Enhanced frontend forms to display Slack-specific fields (webhook URL and channel name). - Implemented CRUD operations for Slack providers, ensuring proper payload structure. - Added E2E tests for Slack notification provider, covering form rendering, validation, and security checks. - Updated translations to include Slack-related text. - Ensured that sensitive information (like tokens) is not exposed in API responses.
24 KiB
Slack Notification Provider — Implementation Specification
Status: Draft Created: 2026-03-12 Target: Single PR (backend + frontend + E2E + docs)
1. Overview
What
Add Slack as a supported notification provider type, using Slack Incoming Webhooks to post messages to channels. The webhook URL (https://hooks.slack.com/services/T.../B.../xxx) acts as the authentication mechanism — no separate API key is required.
How it Fits
Slack follows the exact same pattern as Discord, Gotify, Telegram, and Generic Webhook providers. It:
- Uses
sendJSONPayload()for dispatch (same as Discord/Gotify/Telegram/Webhook) - Requires
textorblocksin the payload (validation already exists insendJSONPayload) - Stores the webhook URL in the
Tokencolumn (same security treatment as Telegram bot token) - Is gated by a feature flag
feature.notifications.service.slack.enabled
Webhook URL Security
The Slack webhook URL contains embedded authentication credentials. The entire Slack webhook URL is sensitive. It must be treated the same as Telegram bot tokens and Gotify tokens:
- Redacted from
GET /api/v1/notifications/providersresponses - Stored in the
Tokencolumn (usesjson:"-"tag) — never returned in plaintext to the frontend - Frontend shows a masked placeholder and "stored" indicator via
HasToken
Decision: Webhook URL stored in Token field.
To reuse the existing token-redaction infrastructure (json:"-" tag, HasToken computed field, write-only semantics), the Slack webhook URL will be stored in the Token column, NOT the URL column. The URL column will hold an optional display-safe channel name. This follows the same security pattern as Telegram (where the bot token goes in Token and the chat ID goes in URL).
| Field | Slack Usage | Example |
|---|---|---|
Token |
Full webhook URL (write-only, redacted) | https://hooks.slack.com/services/T00.../B00.../xxxx |
URL |
Channel display name (optional, user-facing) | #alerts |
HasToken |
true when webhook URL is set |
— |
2. Backend Changes (Go)
2.1 backend/internal/notifications/feature_flags.go
Add constant:
FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled"
Add it below FlagTelegramServiceEnabled in the const block.
2.2 backend/internal/services/notification_service.go
2.2.1 isSupportedNotificationProviderType()
Add "slack" to the switch:
case "discord", "email", "gotify", "webhook", "telegram", "slack":
return true
2.2.2 isDispatchEnabled()
Add slack case:
case "slack":
return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true)
Default enabled (true) to match the Gotify/Telegram/Webhook pattern.
2.2.3 supportsJSONTemplates()
"slack" is already listed in this function (approx line 109). No change needed.
2.2.4 Slack Webhook URL Validation
Add a new function and regex near the existing discordWebhookRegex:
var slackWebhookRegex = regexp.MustCompile(`^https://hooks\.slack\.com/services/T[A-Za-z0-9_-]+/B[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$`)
func validateSlackWebhookURL(rawURL string) error {
if !slackWebhookRegex.MatchString(rawURL) {
return fmt.Errorf("invalid Slack webhook URL: must match https://hooks.slack.com/services/T.../B.../xxx")
}
return nil
}
Validation rules:
- Must be HTTPS
- Host must be
hooks.slack.com - Path must match
/services/T<workspace>/B<bot>/<token>pattern - No IP addresses, no query parameters
- Test hook: add
var validateSlackProviderURLFunc = validateSlackWebhookURLfor testability
2.2.5 sendJSONPayload() — Dispatch path
Step A. Extend the provider routing condition (approx line 465) to include "slack":
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" {
Step B. Add Slack-specific dispatch logic inside the block, after the Telegram block:
if providerType == "slack" {
decryptedWebhookURL := p.Token
if strings.TrimSpace(decryptedWebhookURL) == "" {
return fmt.Errorf("slack webhook URL is not configured")
}
if err := validateSlackProviderURLFunc(decryptedWebhookURL); err != nil {
return err
}
dispatchURL = decryptedWebhookURL
}
Step C. Replace the existing case "slack": block entirely with:
case "slack":
if _, hasText := jsonPayload["text"]; !hasText {
if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks {
if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
jsonPayload["text"] = messageValue
normalizedBody, marshalErr := json.Marshal(jsonPayload)
if marshalErr != nil {
return fmt.Errorf("failed to normalize slack payload: %w", marshalErr)
}
body.Reset()
if _, writeErr := body.Write(normalizedBody); writeErr != nil {
return fmt.Errorf("failed to write normalized slack payload: %w", writeErr)
}
} else {
return fmt.Errorf("slack payload requires 'text' or 'blocks' field")
}
}
}
2.2.6 CreateProvider() — Token field handling
Update the token-clearing logic:
if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" {
provider.Token = ""
}
2.2.7 UpdateProvider() — Token preservation
Update the token-preservation logic:
if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" {
if strings.TrimSpace(provider.Token) == "" {
provider.Token = existing.Token
}
} else {
provider.Token = ""
}
2.3 backend/internal/api/handlers/notification_provider_handler.go
2.3.1 Create() — Type whitelist
Add "slack" to the type validation:
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" &&
providerType != "email" && providerType != "telegram" && providerType != "slack" {
2.3.2 Update() — Type whitelist
Same addition:
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" &&
providerType != "email" && providerType != "telegram" && providerType != "slack" {
2.3.3 Update() — Token preservation on empty update
Add "slack" to the token-keep condition:
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack") &&
strings.TrimSpace(req.Token) == "" {
req.Token = existing.Token
}
2.3.4 Test() — Token write-only guard
Add a Slack guard alongside the existing Gotify check:
if providerType == "slack" && strings.TrimSpace(req.Token) != "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation",
"Slack webhook URL is accepted only on provider create/update")
return
}
2.3.5 classifyProviderTestFailure() — Slack-specific errors
Slack returns plain-text error strings (e.g., "invalid_payload"), not JSON. Add classification after the existing status code matching block:
if strings.Contains(errText, "invalid_payload") ||
strings.Contains(errText, "missing_text_or_fallback") {
return "PROVIDER_TEST_VALIDATION_FAILED", "validation",
"Slack rejected the payload. Ensure your template includes a 'text' or 'blocks' field"
}
if strings.Contains(errText, "no_service") {
return "PROVIDER_TEST_AUTH_REJECTED", "dispatch",
"Slack webhook is revoked or the app is disabled. Create a new webhook"
}
2.3.6 Test() — URL empty guard for Slack
The Test() handler rejects providers where URL is empty. For Slack, URL holds the optional channel display name — the dispatch target is the webhook URL stored in Token. Add Slack exemption:
if providerType != "slack" && strings.TrimSpace(provider.URL) == "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", ...)
return
}
2.3.7 isProviderValidationError() — Slack validation errors
The function checks for specific error message strings to return 400 instead of 500. Add Slack:
strings.Contains(errMsg, "invalid Slack webhook URL")
Without this, malformed Slack webhook URLs return HTTP 500 instead of 400.
2.4 backend/internal/services/notification_service_test.go
Add the following test functions:
| Test Function | Purpose |
|---|---|
TestSlackWebhookURLValidation |
Table-driven: valid/invalid URL patterns for validateSlackWebhookURL |
TestSlackWebhookURLValidation_RejectsHTTP |
Rejects http://hooks.slack.com/... |
TestSlackWebhookURLValidation_RejectsIPAddress |
Rejects https://192.168.1.1/services/... |
TestSlackWebhookURLValidation_RejectsWrongHost |
Rejects https://evil.com/services/... |
TestSlackWebhookURLValidation_RejectsQueryParams |
Rejects URLs with ?token=... |
TestNotificationService_CreateProvider_Slack |
Creates Slack provider, verifies token stored, URL is channel name |
TestNotificationService_CreateProvider_Slack_ClearsTokenField |
Verifies non-Slack types don't keep token |
TestNotificationService_UpdateProvider_Slack_PreservesToken |
Updates name without clearing webhook URL |
TestNotificationService_TestProvider_Slack |
Tests dispatch through mock HTTP server |
TestNotificationService_SendExternal_Slack |
Event filtering + dispatch via goroutine; mock webhook server |
TestNotificationService_Slack_PayloadNormalizesMessageToText |
Minimal template message → text normalization |
TestNotificationService_Slack_PayloadRequiresTextOrBlocks |
Custom template without text/blocks/message fails |
TestFlagSlackServiceEnabled_ConstantValue |
notifications.FlagSlackServiceEnabled == "feature.notifications.service.slack.enabled" |
TestNotificationService_Slack_IsDispatchEnabled |
Feature flag true/false gating |
TestNotificationService_Slack_TokenNotExposedInList |
ListProviders redaction: HasToken=true, Token="" |
2.5 No Changes Required
| File | Reason |
|---|---|
backend/internal/models/notification_provider.go |
Existing Token, URL, HasToken fields sufficient |
backend/internal/notifications/http_wrapper.go |
Slack webhooks are standard HTTPS POST |
backend/internal/api/routes/routes.go |
No new model to auto-migrate |
Dockerfile |
No new dependencies |
.gitignore |
No new artifacts |
codecov.yml |
No new paths to exclude |
.dockerignore |
No new paths |
3. Frontend Changes (React/TypeScript)
3.1 frontend/src/api/notifications.ts
3.1.1 SUPPORTED_NOTIFICATION_PROVIDER_TYPES
Add 'slack':
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = [
'discord', 'gotify', 'webhook', 'email', 'telegram', 'slack'
] as const;
3.1.2 sanitizeProviderForWriteAction()
Add 'slack' to the token-preserving types:
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack') {
delete payload.token;
return payload;
}
3.2 frontend/src/pages/Notifications.tsx
3.2.1 normalizeProviderPayloadForSubmit()
Add 'slack' to the token-preserving types:
if (type === 'gotify' || type === 'telegram' || type === 'slack') {
3.2.2 Provider type <select> options
Add Slack option after Telegram:
<option value="telegram">{t('notificationProviders.telegram')}</option>
<option value="slack">{t('notificationProviders.slack')}</option>
3.2.3 ProviderForm — Conditional field rendering
Add new derived boolean:
const isSlack = type === 'slack';
URL field label update:
{isEmail
? t('notificationProviders.recipients')
: isTelegram
? t('notificationProviders.telegramChatId')
: isSlack
? t('notificationProviders.slackChannelName')
: <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
URL field validation update — Slack URL is optional:
required: (isEmail || isSlack) ? false : (t('notificationProviders.urlRequired') as string),
validate: (isEmail || isTelegram || isSlack) ? undefined : validateUrl,
URL placeholder update:
placeholder={isEmail ? 'user@example.com, admin@example.com'
: isTelegram ? '987654321'
: isSlack ? '#general'
: type === 'discord' ? 'https://discord.com/api/webhooks/...'
: type === 'gotify' ? 'https://gotify.example.com/message'
: 'https://example.com/webhook'}
Token field visibility — show for Slack:
{(isGotify || isTelegram || isSlack) && (
Token field label — Slack-specific:
{isSlack
? t('notificationProviders.slackWebhookUrl')
: isTelegram
? t('notificationProviders.telegramBotToken')
: t('notificationProviders.gotifyToken')}
Token placeholder — Slack-specific:
placeholder={initialData?.has_token
? t('notificationProviders.gotifyTokenKeepPlaceholder')
: isSlack
? t('notificationProviders.slackWebhookUrlPlaceholder')
: isTelegram
? t('notificationProviders.telegramBotTokenPlaceholder')
: t('notificationProviders.gotifyTokenPlaceholder')}
3.2.4 supportsJSONTemplates()
Add 'slack':
return t === 'discord' || t === 'gotify' || t === 'webhook' || t === 'telegram' || t === 'slack';
3.2.5 useEffect for clearing token
Update to include 'slack':
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack') {
3.3 frontend/src/locales/en/translation.json
Add these keys inside the "notificationProviders" object, after the Telegram keys:
"slack": "Slack",
"slackWebhookUrl": "Webhook URL",
"slackWebhookUrlPlaceholder": "https://hooks.slack.com/services/T.../B.../xxx",
"slackChannelName": "Channel Name (optional)",
"slackChannelNameHelp": "Display name for the channel. The actual channel is determined by the webhook configuration."
3.4 frontend/src/pages/__tests__/Notifications.test.tsx
Add test cases:
| Test | Purpose |
|---|---|
it('shows 6 supported provider type options including slack') |
Verify options: discord, gotify, webhook, email, telegram, slack |
it('shows token field when slack type selected') |
Token input visible when slack selected |
it('hides token field when switching from slack to discord') |
Token input removed on switch |
it('submits slack provider with token as webhook URL') |
Verify createProvider receives token field |
it('does not require URL for slack') |
No validation error when URL empty for slack |
Update the existing 'shows supported provider type options' test to expect 6 options instead of 5.
3.5 Accessibility
- The token field for Slack uses
htmlFor="provider-gotify-token"(existingid) aria-describedbypoints togotify-token-stored-hintwhenhas_tokenis set- URL field label correctly associates via
htmlFor="provider-url" - Keyboard navigation order unchanged within form
- Screen readers announce "Webhook URL" for the token field when Slack is selected
4. E2E Tests (Playwright)
4.1 New File: tests/settings/slack-notification-provider.spec.ts
Follow the exact same structure as tests/settings/telegram-notification-provider.spec.ts.
Test Structure
Slack Notification Provider
├── Form Rendering
│ ├── should show webhook URL field and channel name when slack type selected
│ ├── should toggle form fields when switching between slack and discord types
│ └── should show JSON template section for slack
├── CRUD Operations
│ ├── should create slack notification provider
│ ├── should edit slack notification provider and preserve webhook URL
│ ├── should test slack notification provider
│ └── should delete slack notification provider
└── Security
├── GET response should NOT expose webhook URL
└── webhook URL should NOT be present in URL field
Key Implementation Details
Mock route patterns:
await page.route('**/api/v1/notifications/providers', async (route, request) => { ... });
await page.route('**/api/v1/notifications/providers/*', async (route, request) => { ... });
await page.route('**/api/v1/notifications/providers/test', async (route, request) => { ... });
Provider mock data:
{
id: 'slack-provider-1',
name: 'Slack Alerts',
type: 'slack',
url: '#alerts',
has_token: true,
enabled: true,
notify_proxy_hosts: true,
notify_certs: true,
notify_uptime: false,
}
Create payload verification:
expect(capturedPayload?.type).toBe('slack');
expect(capturedPayload?.token).toBe('https://hooks.slack.com/services/T00000/B00000/xxxx');
expect(capturedPayload?.url).toBe('#alerts');
expect(capturedPayload?.gotify_token).toBeUndefined();
Security tests — GET response must NOT contain webhook URL:
expect(provider.token).toBeUndefined();
expect(provider.gotify_token).toBeUndefined();
const responseStr = JSON.stringify(provider);
expect(responseStr).not.toContain('hooks.slack.com');
expect(responseStr).not.toContain('/services/');
Firefox-stable patterns (mandatory for all save/delete actions):
// CORRECT: Register listeners BEFORE the click
await Promise.all([
page.waitForResponse(
(resp) =>
/\/api\/v1\/notifications\/providers\/slack-edit-id/.test(resp.url()) &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
(resp) =>
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
resp.request().method() === 'GET' &&
resp.status() === 200
),
page.getByTestId('provider-save-btn').click(),
]);
4.2 Updates to tests/settings/notifications-payload.spec.ts
Add Slack to the payload matrix test scenario array (alongside Discord, Gotify, Webhook):
{
type: 'slack',
name: `slack-matrix-${Date.now()}`,
url: '#slack-alerts',
}
4.3 No Changes to Other E2E Files
tests/settings/notifications.spec.ts— operates on Discord by default, no changes neededtests/settings/telegram-notification-provider.spec.ts— Telegram-specific, no changes needed
5. Documentation Updates
5.1 docs/features/notifications.md
Supported Services table — Add Slack row:
| Service | JSON Templates | Native API | Rich Formatting |
|---|---|---|---|
| Slack | ✅ Yes | ✅ Webhooks | ✅ Blocks + Attachments |
Add Slack section after "Email Notifications", before "Planned Provider Expansion":
### Slack Notifications
Slack notifications post messages to channels using Incoming Webhooks.
**Setup:**
1. In Slack, go to your workspace's **App Management** → **Incoming Webhooks**
2. Create a new webhook and select the target channel
3. Copy the webhook URL
4. In Charon, go to **Settings** → **Notifications** → **Add Provider**
5. Select **Slack** as the service type
6. Paste the webhook URL in the Webhook URL field
7. Optionally enter a channel display name
8. Configure notification triggers and save
> **Note:** The webhook URL is stored securely and never exposed in API responses.
Update "Planned Provider Expansion" — Remove Slack from the planned list since it is now implemented.
5.2 CHANGELOG.md
Add entry under ## [Unreleased]:
### Added
- Slack notification provider with Incoming Webhook support
6. Commit Slicing Strategy
Decision: Single PR
Rationale:
- Slack follows an established, well-tested pattern (identical to Telegram)
- All changes are tightly coupled: backend type support, frontend form, E2E tests
- Estimated diff is moderate (~670 lines across 12 files)
- No schema migration or infrastructure changes
- No cross-domain risk (no security module changes, no Caddy changes)
- Feature flag provides safe rollback without code revert
PR Scope
| Domain | Files | Lines (est.) |
|---|---|---|
| Backend - Feature flag | feature_flags.go |
+1 |
| Backend - Service | notification_service.go |
+40 |
| Backend - Handler | notification_provider_handler.go |
+15 |
| Backend - Tests | notification_service_test.go |
+150 |
| Frontend - API | notifications.ts |
+5 |
| Frontend - Page | Notifications.tsx |
+30 |
| Frontend - i18n | translation.json |
+5 |
| Frontend - Tests | Notifications.test.tsx |
+40 |
| E2E Tests | slack-notification-provider.spec.ts (new) |
+350 |
| E2E Tests | notifications-payload.spec.ts |
+5 |
| Docs | notifications.md, CHANGELOG.md |
+30 |
Total estimated: ~670 lines
Validation Gates
go test ./backend/...— all backend tests pass (including new Slack tests)cd frontend && npx vitest run— frontend unit tests passnpx playwright test tests/settings/slack-notification-provider.spec.ts --project=firefox— Slack E2E passesnpx playwright test tests/settings/notifications-payload.spec.ts --project=firefox— payload matrix passesmake lint-fast— staticcheck passes- Manual smoke test: create Slack provider → send test notification → verify in Slack channel
Rollback
If issues arise post-merge:
- Set
feature.notifications.service.slack.enabled = falsein Settings → Feature Flags - This immediately stops all Slack dispatch without code changes
- Existing Slack providers remain in DB but are non-dispatch
7. Risk Assessment
7.1 Webhook URL Security — HIGH
Risk: Webhook URL contains embedded credentials. If exposed in API responses, anyone with read access to the Charon API could post to the Slack channel.
Mitigation: Store in Token field (uses json:"-" tag). Use HasToken computed field for frontend indication. Follow identical pattern to Telegram bot token / Gotify token.
Verification: E2E security test GET response should NOT expose webhook URL explicitly checks the API response body.
7.2 Rate Limiting — LOW
Risk: Slack enforces 1 message/second/webhook. Burst notifications could trigger rate limiting.
Mitigation: Not addressed in this PR. The existing Charon notification system dispatches per-event and does not batch. Acceptable for initial rollout. Future work could add per-provider rate limiting.
7.3 Slack Webhook URL Format Changes — LOW
Risk: Slack could change their webhook URL format, breaking the validation regex.
Mitigation: Regex is simple and based on the documented format. Only validateSlackWebhookURL needs updating. Feature flag allows immediate disable as a workaround.
7.4 Plain-Text Error Responses — MEDIUM
Risk: Discord returns JSON error responses. Slack returns plain-text error strings (e.g., "invalid_payload", "channel_not_found"). Error classification must handle both patterns.
Mitigation: Added Slack-specific string matching in classifyProviderTestFailure. The existing fallback (PROVIDER_TEST_FAILED) handles unknown patterns gracefully.
7.5 Payload Normalization — LOW
Risk: Minimal template produces message key; Slack requires text. Without normalization, the default template would fail.
Mitigation: The sendJSONPayload Slack case block normalizes message → text automatically (same pattern as Discord message → content and Telegram message → text).
7.6 Workflow Builder Webhooks — LOW
Risk: Slack Workflow Builder uses /triggers/... URLs, not /services/.... These are not supported by the current validation regex.
Mitigation: Document as a known limitation. Workflow Builder webhooks can be used via the Generic Webhook provider type. Future work could extend the regex to support both formats.
8. Acceptance Criteria
"slack"is a valid provider type in both backend and frontend- Feature flag
feature.notifications.service.slack.enabledgates dispatch (default: enabled) - Slack webhook URL is stored in
Tokenfield, never exposed in GET responses HasTokenistruewhen webhook URL is set- Create / Update / Delete Slack providers works via API and UI
- Test notification dispatches successfully to a Slack webhook
- Minimal template auto-normalizes
message→textfor Slack payloads - All existing notification tests continue to pass (zero regressions)
- New Slack-specific unit tests pass (
go test ./backend/...) - Frontend unit tests pass (
npx vitest run) - E2E tests pass on Firefox (
--project=firefox) make lint-fast(staticcheck) passes- Documentation updated (
notifications.md,CHANGELOG.md)