Skip to content

feat: extract notification delivery into go_notify_yourself module - #1253

Open
Wikid82 wants to merge 16 commits into
developmentfrom
feature/notifications-engine-extraction
Open

feat: extract notification delivery into go_notify_yourself module#1253
Wikid82 wants to merge 16 commits into
developmentfrom
feature/notifications-engine-extraction

Conversation

@Wikid82

@Wikid82 Wikid82 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

Extracts Charon's notification delivery (HTTP dispatch, retries, SSRF guards, and all provider payload/template logic) into a standalone, reusable Go module — go_notify_yourself v0.1.0 — and cuts Charon over to consume it. Full design/scoping is in docs/plans/notifications_extraction_spec.md (included in this PR).

Motivation: the maintainer now runs multiple projects that all need notification delivery. Rather than re-implementing it per project, this pulls the engine out behind a small, dependency-free public API (notify.Message, notify.Sender, transport.Wrapper) that any Go project can go get, with a longer-term direction of becoming a Go equivalent of Apprise — scoped for now to exactly the providers Charon already ships (no new integrations added).

What moved vs. what stayed

  • Moved: SSRF-safe HTTP transport with retry/backoff, and all 7 provider packages (Discord, Slack, Gotify, Pushover, Ntfy, generic webhook, Telegram) plus email/Mailer+TemplateRenderer — now live in go_notify_yourself, decoupled from Charon via two DI seams (ClientFactory, URLValidator) so the module has zero github.com/Wikid82/charon/* imports.
  • Stayed in Charon, unchanged: GORM models, provider CRUD, DB-backed feature-flag gating, Charon's event-type routing, security_notification_service.go/enhanced_security_notification_service.go, and the entire frontend.
  • New in Charon: a small adapter layer (notify_client_adapter.go, notify_provider_adapter.go, notify_email_adapter.go) wiring internal/network/internal/security into the module's DI seams.

Deliberate behavior changes (both explicitly approved during scoping)

  1. Discord notifications now retry on transient failures. Discord dispatch previously bypassed the shared HTTP wrapper entirely (no retry/backoff). It's now folded onto the same shared transport.Wrapper as every other provider — a real, user-visible reliability improvement, not just a refactor.
  2. Telegram was an undocumented 7th provider type outside the original scope and has been included alongside the other six for consistency (no provider left behind as a special case).

The detailed JSON template's backward compatibility was preserved via a legacyDetailedTemplate compatibility shim in notify_provider_adapter.go — existing custom integrations parsing the old flat JSON shape see zero payload change.

Validation

  • go build/go vet/staticcheck: clean
  • Full backend test suite: passing
  • Coverage: 89.2%/89.3% (gate 87%)
  • GORM security scan: 0 CRITICAL/HIGH
  • lefthook run pre-commit: clean
  • Local patch coverage: 100%
  • Targeted Playwright regression (firefox), all notification-provider specs + a11y: 88/88 passed, 0 failures
  • Manually verified against a running E2E container: test notifications sent and received successfully with zero changes needed inside Charon's test container

Deferred follow-ups (flagged by Supervisor review, not blocking this PR)

  • Two provider-preview API handlers still use the old RenderTemplate path rather than the new module — minor divergence from real dispatch, worth a follow-up ticket.
  • Some webhook URL-validation logic is duplicated between Charon's adapters and the new module (bounded, low-risk, but worth deduplicating later).

Test plan

  • Backend unit/integration tests pass (go test ./...)
  • Coverage gate met (89.2%/89.3% vs 87%)
  • GORM security scan clean
  • Targeted Playwright regression green (8 spec files, firefox, 88/88)
  • Manual round-trip test notification verified against running E2E container
  • Reviewer spot-check of the two deliberate behavior changes above

🤖 Generated with Claude Code

Wikid82 added 16 commits August 14, 2026 17:14
Scopes the extraction of Charon's notification delivery (HTTP wrapper,
provider payload builders, email dispatch) into the standalone
go_notify_yourself module, including the decoupling design, new
module's public API, and the two-repo commit slicing strategy.
Discord dispatch now routes through buildNotifySender/transport.Wrapper
(the extracted go_notify_yourself module) instead of the legacy
sendJSONPayload path. This is a deliberate behavior change, not just a
refactor: today's Discord dispatch bypasses the old HTTPWrapper entirely
(direct network/security calls, no retry/backoff). Discord notifications
now retry on transient failures, consistent with every other provider.

SendExternal and TestProvider route Discord through a shared
dispatchViaNotify/testProviderViaNotify seam, gated by a
notifyMigratedProviderTypes allowlist that will grow one provider at a
time as the rest of the migration lands. The notify.Message sent for
Discord carries HostName/HostIP/ServiceCount/Services under Data so a
provider configured with the old "detailed" template keeps rendering via
legacyDetailedTemplate's backward-compat shape.

Tests exercising Discord dispatch now inject a capturing fake
RoundTripper via a new WithNotifyTransportWrapper test option, since the
extracted module's own Discord webhook validation only accepts
discord.com/canary.discord.com hosts and can no longer be pointed at an
httptest.Server the way pre-cutover tests were.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Slack dispatch now routes through buildNotifySender/transport.Wrapper via
the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord
in notifyMigratedProviderTypes. Field mapping matches the old
sendJSONPayload branch exactly: the Slack webhook URL comes from
provider.Token (provider.URL remains an unused placeholder), so no
provider-facing behavior changes.

Tests exercising Slack dispatch now inject a capturing fake RoundTripper
via WithNotifyTransportWrapper and use a real hooks.slack.com-shaped
webhook URL, since the extracted module's own Slack webhook validation
enforces the same URL shape Charon's service-level validator did and can
no longer be pointed at an httptest.Server via a validator override.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Gotify dispatch now routes through buildNotifySender/transport.Wrapper
via the shared dispatchViaNotify/testProviderViaNotify seam, joining
Discord and Slack in notifyMigratedProviderTypes. Field mapping is
unchanged from the old sendJSONPayload branch: URL from provider.URL,
token sent as X-Gotify-Key when non-empty.

Two TestProvider tests that dispatch to a local httptest.Server now set
CHARON_ENV=test explicitly, since the extracted module's transport wrapper
gates plain-HTTP/localhost dispatch on that env var rather than the old
implicit os.Args[0]-based test-binary detection.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Pushover dispatch now routes through buildNotifySender/transport.Wrapper
via the shared dispatchViaNotify/testProviderViaNotify seam, joining
Discord, Slack, and Gotify in notifyMigratedProviderTypes. Field mapping
matches the old sendJSONPayload branch: user key from provider.URL, API
token from provider.Token, injected into the payload's token/user fields
server-side after template rendering (same anti-injection behavior as
before). The extracted module's Config leaves BaseURL empty, so dispatch
targets Pushover's real production API exactly as before (the old
svc.pushoverAPIBaseURL test-only override doesn't apply to this path);
new tests use a capturing fake RoundTripper to verify the notify-path
dispatch without a real network call.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Ntfy dispatch now routes through buildNotifySender/transport.Wrapper via
the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord,
Slack, Gotify, and Pushover in notifyMigratedProviderTypes. Field mapping
is unchanged from the old sendJSONPayload branch: URL from provider.URL,
token sent as an "Authorization: Bearer <token>" header when non-empty.

New TestProvider/SendExternal tests dispatch to a local httptest.Server
with CHARON_ENV=test set explicitly, since the extracted module's
transport wrapper gates plain-HTTP dispatch on that env var rather than
the old implicit os.Args[0]-based test-binary detection.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Telegram dispatch now routes through buildNotifySender/transport.Wrapper
via the shared dispatchViaNotify/testProviderViaNotify seam, joining
Discord, Slack, Gotify, Pushover, and Ntfy in notifyMigratedProviderTypes.
Field mapping matches the old sendJSONPayload branch: bot token from
provider.Token (embedded in the dispatch URL path, not a header), chat ID
from provider.URL injected into the payload's chat_id field after
template rendering. The extracted module's Config leaves BaseURL empty,
so dispatch targets the real Telegram Bot API exactly as before (the old
svc.telegramAPIBaseURL test-only override doesn't apply to this path);
new tests use a capturing fake RoundTripper to verify the notify-path
dispatch without a real network call.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Webhook and generic-webhook dispatch now route through
buildNotifySender/transport.Wrapper via the shared
dispatchViaNotify/testProviderViaNotify seam, joining every other
provider type except email in notifyMigratedProviderTypes.

CreateProvider and UpdateProvider's custom-template preview validation
now calls providers/webhook.RenderPreview instead of the old
RenderTemplate, so the preview payload matches the same
Title/Message/Time/EventType/Data shape actual dispatch uses — a custom
template referencing {{index .Data "..."}} now validates correctly at
save time instead of failing against a flat map that had no Data field.
RenderTemplate itself is untouched and still backs the provider/template
preview API handlers, which are out of scope for this cutover.

Three TestProvider tests that dispatch to a local httptest.Server now set
CHARON_ENV=test explicitly, matching the earlier Gotify/Ntfy commits'
rationale: the extracted module's transport wrapper gates plain-HTTP
dispatch on that env var rather than the old implicit os.Args[0]-based
test-binary detection.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
SendExternal's email branch and TestEmailProvider now dispatch through
providers/email (the extracted notify module's email package) via
NewNotifyEmailConfig (notify_email_adapter.go) instead of calling the
mail service directly. dispatchEmail itself is left in place, unused by
production code after this commit — cleanup is a separate, later commit.
TestEmailProvider builds its own inline email.Config (reusing the same
Renderer/Mailer adapters) because its test-send subject prefix
("[Charon Test] ") and forced "email_system_event.html" template differ
from NewNotifyEmailConfig's production values.

This is a deliberate, documented behavior change: dispatchEmail's old
fallback — building a manual plain HTML body and still sending when
template rendering fails — does not exist in the extracted module.
email.Client.Send aborts before calling Mailer.Send when the configured
Renderer returns an error, so a broken/missing email template now fails
the notification (or test send) closed instead of degrading gracefully
to a generic fallback body. Tests that relied on the old fallback path
were rewritten to assert the new fail-closed behavior.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
The extracted notify module's email.Client.Send aborts before calling
Mailer.Send whenever the configured TemplateRenderer returns an error,
so the email cutover to providers/email (commit c073f4b) silently
started failing dispatch closed on any template-render failure, instead
of degrading gracefully like pre-extraction dispatchEmail did.

Fix lives entirely in mailServiceTemplateRendererAdapter.Render
(notify_email_adapter.go): on a RenderNotificationEmail failure it now
logs a warning and returns a locally-composed plain-HTML fallback body
(fallbackEmailBody) instead of propagating the error, restoring the old
fail-open-with-degraded-body behavior for both dispatchEmailViaNotify
and TestEmailProvider without touching the extracted module or any
other already-migrated provider. Real Mailer/SMTP transport failures
are unaffected and still propagate as errors.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
The notification delivery engine (HTTP dispatch wrapper, per-provider
payload/validation/template logic, and unused engine/router scaffolding)
now lives in the external github.com/Wikid82/go_notify_yourself module,
consumed via the Charon-side adapters added in earlier commits on this
branch. This commit removes the now-dead in-repo copy of that logic:

- Deletes internal/notifications/ in full (http_wrapper, http_client_executor,
  engine, router + tests) now that every provider type dispatches through
  the extracted module's adapters.
- Folds internal/notifications/feature_flags.go's Setting-table key
  constants into internal/services (Charon policy, not engine logic) so
  the notifications package can be removed entirely rather than left
  behind as a single-file package.
- Removes the now-unreachable legacy JSON-payload dispatch path
  (sendJSONPayload and its per-provider validation/header/dispatch-URL
  helpers, the old dispatchEmail/sanitizeForEmail path, and the dead
  isPrivateIP wrapper) from notification_service.go, along with the test
  coverage that exercised those functions directly. Validation logic still
  reachable from live CRUD/test-send code paths (Discord/Slack URL
  validation, the template-preview endpoint) is kept unchanged.
- Deletes the old engine-level integration test, superseded by the
  DI-seam-level coverage already added alongside the transport adapter.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
Notes that outbound notification dispatch (all seven HTTP provider types
plus email) now goes through the external go_notify_yourself module via
Charon-supplied SSRF/SMTP/template adapters, replacing the removed
internal/notifications package.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
dispatchViaNotify and testProviderViaNotify each have a defensive
error-handling branch for buildNotifySender rejecting an unrecognized
provider type — unreachable through the public SendExternal/TestProvider
entry points since their upstream type allowlists only ever pass
buildNotifySender a type it supports, but worth covering directly to close
the patch-coverage gap left by the surrounding dead-code removal.

Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX
go_notify_yourself has now been pushed to GitHub and tagged v0.1.0.
Remove the local filesystem replace directive used during development
and depend on the real published module instead.
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.52830% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
backend/internal/services/notification_service.go 98.61% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@Wikid82 Wikid82 self-assigned this Aug 15, 2026
@github-advanced-security

Copy link
Copy Markdown
Contributor

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Supply Chain Verification Results

PASSED

📦 SBOM Summary

  • Components: 1754

🔍 Vulnerability Scan

Severity Count
🔴 Critical 0
🟠 High 0
🟡 Medium 5
🟢 Low 2
Total 11

📎 Artifacts

  • SBOM (CycloneDX JSON) and Grype results available in workflow artifacts

Generated by Supply Chain Verification workflow • View Details

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants