Basic impl added

This commit is contained in:
2025-10-16 21:22:51 -07:00
parent 097ca99788
commit 9087a710e5
43 changed files with 7616 additions and 107 deletions
+39
View File
@@ -0,0 +1,39 @@
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Build artifacts
bin/
*.o
*.a
*.so
# Test files
*_test.go
testdata/
# Documentation
*.md
docs/
# CI/CD
.github/
.gitlab-ci.yml
# Kubernetes
k8s/
*.yaml
!config.yaml
# Development
.env
.env.local
# macOS
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/notifier.iml" filepath="$PROJECT_DIR$/.idea/notifier.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+320
View File
@@ -0,0 +1,320 @@
# Notifier Microservice Architecture
## Overview
The Notifier microservice is designed to provide a flexible, scalable notification delivery system that supports multiple notification channels (SMTP, Slack, Ntfy, Stdout) with both REST and gRPC APIs.
## Architecture Principles
- **Clean Architecture**: Domain-driven design with clear separation of concerns
- **Interface-based Design**: All core components use interfaces for maximum flexibility
- **Pluggable Notifiers**: Easy to add new notification providers
- **Queue Abstraction**: Supports both local and distributed queues (Kafka)
- **Configuration-driven**: Viper-based configuration with environment variable support
- **Cloud-native**: Containerized with Kubernetes support
## Core Components
### 1. Domain Layer (`internal/domain/`)
The domain layer defines the core business logic and interfaces:
#### `notification.go`
- **Notification**: Core notification entity with metadata, status tracking, and retry logic
- **Priority**: Enumeration for notification urgency (Low, Normal, High, Critical)
- **NotificationType**: Supported notification channels (Email, Slack, Ntfy, Stdout)
- **NotificationStatus**: Lifecycle states (Pending, Queued, Processing, Sent, Failed, Retrying)
- **NotificationResult**: Outcome of notification delivery attempts
- **NotificationFilter**: Query interface for retrieving notifications
#### `notifier.go`
- **Notifier**: Core interface that all notification implementations must satisfy
- **NotifierFactory**: Factory pattern for creating notifier instances
- **NotificationService**: High-level service interface for notification operations
- **NotificationStats**: Statistics and metrics about notification processing
#### `queue.go`
- **Queue**: Interface for notification queue implementations
- **QueueMessage**: Wrapper around notifications with queue-specific metadata
- **QueueConfig**: Configuration for queue implementations
- **LocalQueueConfig**: In-memory queue configuration
- **KafkaQueueConfig**: Distributed Kafka queue configuration
### 2. Queue Implementation (`internal/queue/`)
#### `local.go`
- In-memory queue implementation using Go channels
- Optional disk persistence for durability
- Thread-safe with mutex protection
- Supports enqueue, dequeue, ack, and nack operations
- Configurable buffer size and retry behavior
### 3. Notifier Implementations (`internal/notifier/`)
#### `notifier.go`
- **Factory**: Manages and creates notifier instances
- **BaseNotifier**: Common functionality shared by all notifiers
- Validation and context checking utilities
#### `stdout.go`
- Simple stdout notifier for debugging and development
- Prints notifications to console with formatted output
#### `smtp.go`
- Email notifications via SMTP
- Supports TLS/SSL
- Configurable SMTP server, port, and authentication
- RFC-compliant email message formatting
#### `ntfy.go`
- Integration with ntfy.sh push notification service
- Supports custom ntfy servers
- Priority mapping and metadata support
- Rich notification features (tags, click actions, attachments)
#### `slack.go`
- Slack webhook integration
- Channel-specific webhook support
- Rich message formatting with blocks
- Priority indicators and custom branding
### 4. Configuration (`internal/config/`)
#### `config.go`
- Viper-based configuration management
- Support for YAML config files and environment variables
- Default value handling
- Comprehensive validation
- Hierarchical configuration structure:
- Server settings (ports, host, mode)
- Queue configuration
- Notifier credentials
- Logging settings
- Metrics and observability
- Health check configuration
### 5. API Layer
#### gRPC API (`api/grpc/`)
**`notifier.proto`**
- Protocol buffer definitions for the gRPC service
- Operations:
- `SendNotification`: Send single notification
- `SendBatchNotifications`: Send multiple notifications
- `GetNotification`: Retrieve notification by ID
- `ListNotifications`: Query notifications with filters
- `CancelNotification`: Cancel pending notification
- `RetryNotification`: Retry failed notification
- `GetStats`: Retrieve service statistics
- `HealthCheck`: Service health verification
#### REST API (`api/rest/`)
**`handlers.go`**
- HTTP handlers for REST endpoints
- Request validation and error handling
- JSON serialization/deserialization
**`router.go`**
- Gorilla mux router configuration
- Middleware for logging and CORS
- Route definitions matching gRPC operations
**`types.go`**
- REST API request/response types
- Domain model conversions
- Validation logic
### 6. Entry Points (`cmd/`)
#### `grpcserver/main.go`
- Standalone gRPC server
- Service initialization and dependency injection
- Graceful shutdown handling
#### `restserver/main.go`
- Standalone REST server
- HTTP server configuration
- Graceful shutdown handling
## Data Flow
### Sending a Notification
```
Client Request (REST/gRPC)
API Handler
NotificationService.Send()
Queue.Enqueue()
[Notification queued]
Worker dequeues (Queue.Dequeue())
NotifierFactory.Create()
Notifier.Send()
Provider API (SMTP/Slack/Ntfy/Stdout)
Queue.Ack() or Queue.Nack()
Update notification status
[Notification sent or failed]
```
## Queue Strategies
### Local Queue
- In-memory implementation using Go channels
- Fast and simple for single-instance deployments
- Optional disk persistence for durability
- Suitable for development and small-scale production
### Kafka Queue (Future)
- Distributed queue for multi-instance deployments
- Guarantees delivery across service restarts
- Horizontal scalability
- Exactly-once semantics with idempotence
- Suitable for high-throughput production environments
## Notification Lifecycle
1. **Pending**: Notification created but not yet queued
2. **Queued**: Added to queue, waiting for processing
3. **Processing**: Worker has dequeued and is sending
4. **Sent**: Successfully delivered to provider
5. **Failed**: Delivery failed after max retries
6. **Retrying**: Temporarily failed, will retry
## Retry Strategy
- Configurable max retries (default: 3)
- Pluggable backoff strategies:
- **Exponential**: 2^n delay between retries
- **Linear**: Fixed increment between retries
- **Fixed**: Constant delay between retries
## Configuration Management
### Hierarchy (highest to lowest priority)
1. Environment variables (prefixed with `NOTIFIER_`)
2. Configuration file (config.yaml)
3. Default values
### Example Environment Variables
```bash
NOTIFIER_SERVER_GRPC_PORT=50051
NOTIFIER_SERVER_REST_PORT=8080
NOTIFIER_QUEUE_TYPE=local
NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com
NOTIFIER_NOTIFIERS_SLACK_WEBHOOK_URL=https://hooks.slack.com/...
```
## Deployment Strategies
### Docker Compose
- Single-host deployment
- All components in one compose file
- Optional Kafka, Prometheus, and Grafana services
- Volume mounts for configuration and persistence
### Kubernetes
- Multi-instance deployment with HPA
- Separate services for REST and gRPC
- ConfigMaps for configuration
- Secrets for credentials
- Ingress for external access
- Health checks and readiness probes
- Resource limits and requests
### Scaling Considerations
#### Horizontal Scaling
- Multiple instances can run concurrently
- Use Kafka queue for distributed message processing
- Stateless design allows easy scaling
#### Vertical Scaling
- Increase worker count per instance
- Tune queue buffer sizes
- Adjust resource limits
## Observability
### Metrics (Prometheus)
- Total notifications sent/failed
- Notifications by type and status
- Average latency
- Queue size
- Worker utilization
### Health Checks
- Dedicated health endpoint
- Component-level health reporting
- Kubernetes liveness/readiness probes
### Logging
- Structured JSON logging
- Configurable log levels
- Request/response logging
- Error tracking
## Security
### Authentication
- API keys for REST endpoints (to be implemented)
- mTLS for gRPC (to be implemented)
- Kubernetes RBAC for service account
### Authorization
- Per-notifier credential management
- Secrets stored in Kubernetes Secrets or external secret manager
- No credentials in configuration files
### Network Security
- Non-root container user
- Read-only root filesystem where possible
- Minimal container image (Alpine-based)
- Security context restrictions
## Extension Points
### Adding a New Notifier
1. Create new file in `internal/notifier/`
2. Implement the `domain.Notifier` interface:
- `Send(ctx, notification) -> result`
- `Type() -> NotificationType`
- `Validate(notification) -> error`
- `Close() -> error`
3. Add configuration struct to `internal/config/`
4. Register in factory during initialization
5. Update protobuf and REST API types
6. Add configuration example to `config.yaml`
### Adding a New Queue Implementation
1. Create new file in `internal/queue/`
2. Implement the `domain.Queue` interface
3. Add configuration to `domain.QueueConfig`
4. Update queue factory/initialization logic
5. Add configuration example
## Next Steps for Implementation
1. **Generate Protocol Buffers**: Run `make proto-gen` to generate gRPC code
2. **Implement Service Layer**: Create the main service that ties everything together
3. **Implement Server Main Functions**: Wire up dependencies in `cmd/`
4. **Add Tests**: Unit tests for notifiers, integration tests for queue
5. **Add Kafka Queue**: Implement distributed queue using Kafka
6. **Add Metrics**: Prometheus instrumentation
7. **Add Authentication**: API key or OAuth support
8. **Add Rate Limiting**: Prevent abuse and manage provider quotas
9. **Add Notification Templates**: Support for templated messages
10. **Add Webhooks**: Allow callbacks on notification status changes
+55
View File
@@ -0,0 +1,55 @@
# Build stage
FROM golang:1.23.2-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git make
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build binaries
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o grpcserver ./cmd/grpcserver
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o restserver ./cmd/restserver
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1000 notifier && \
adduser -D -u 1000 -G notifier notifier
# Set working directory
WORKDIR /app
# Copy binaries from builder
COPY --from=builder /build/grpcserver /app/
COPY --from=builder /build/restserver /app/
# Copy default config (can be overridden with volume mount)
COPY config.yaml /app/config.yaml
# Create directory for queue persistence
RUN mkdir -p /var/lib/notifier && \
chown -R notifier:notifier /var/lib/notifier
# Change to non-root user
USER notifier
# Expose ports
EXPOSE 8080 50051 9090 8081
# Default to running both servers
# Can be overridden with docker run command
CMD ["sh", "-c", "/app/grpcserver & /app/restserver"]
+98
View File
@@ -0,0 +1,98 @@
.PHONY: proto proto-gen proto-clean deps build run-grpc run-rest run-both test lint docker-build docker-run clean
# Variables
PROTO_DIR=api/grpc
PROTO_FILE=$(PROTO_DIR)/notifier.proto
PROTO_OUT=$(PROTO_DIR)/pb
GO_MODULE=$(shell head -n 1 go.mod | awk '{print $$2}')
# Generate protobuf code
proto-gen:
@echo "Generating protobuf code..."
@mkdir -p $(PROTO_OUT)
protoc --go_out=$(PROTO_OUT) --go_opt=paths=source_relative \
--go-grpc_out=$(PROTO_OUT) --go-grpc_opt=paths=source_relative \
$(PROTO_FILE)
@echo "Protobuf code generated successfully"
# Clean generated protobuf code
proto-clean:
@echo "Cleaning generated protobuf code..."
@rm -rf $(PROTO_OUT)
@echo "Cleaned"
# Install dependencies
deps:
@echo "Installing dependencies..."
go mod download
go mod tidy
@echo "Dependencies installed"
# Install protoc plugins
proto-deps:
@echo "Installing protoc plugins..."
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
@echo "Protoc plugins installed"
# Build binaries
build:
@echo "Building binaries..."
@mkdir -p bin
go build -o bin/grpcserver ./cmd/grpcserver
go build -o bin/restserver ./cmd/restserver
@echo "Binaries built successfully"
# Run gRPC server
run-grpc:
@echo "Running gRPC server..."
go run ./cmd/grpcserver/main.go
# Run REST server
run-rest:
@echo "Running REST server..."
go run ./cmd/restserver/main.go
# Run tests
test:
@echo "Running tests..."
go test -v -race -cover ./...
# Run linter
lint:
@echo "Running linter..."
golangci-lint run ./...
# Build Docker image
docker-build:
@echo "Building Docker image..."
docker build -t notifier:latest .
@echo "Docker image built successfully"
# Run Docker container
docker-run:
@echo "Running Docker container..."
docker run -p 8080:8080 -p 50051:50051 -v $(PWD)/config.yaml:/app/config.yaml notifier:latest
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
@rm -rf bin/
@rm -rf $(PROTO_OUT)
@echo "Cleaned"
# Help
help:
@echo "Available targets:"
@echo " proto-gen - Generate protobuf code"
@echo " proto-clean - Clean generated protobuf code"
@echo " proto-deps - Install protoc plugins"
@echo " deps - Install Go dependencies"
@echo " build - Build binaries"
@echo " run-grpc - Run gRPC server"
@echo " run-rest - Run REST server"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " docker-build - Build Docker image"
@echo " docker-run - Run Docker container"
@echo " clean - Clean build artifacts"
+329
View File
@@ -0,0 +1,329 @@
# Quick Start Guide
## Prerequisites
1. Go 1.23.2 or higher installed
2. Dependencies installed: `go mod tidy`
## Running the REST Server
### Option 1: Using go run
```bash
go run ./cmd/restserver/main.go
```
### Option 2: Build and run
```bash
go build -o bin/restserver ./cmd/restserver
./bin/restserver
```
### Option 3: Using Make
```bash
make build
make run-rest
```
The server will start on `http://localhost:8080` by default.
## Testing with cURL
### 1. Health Check
```bash
curl http://localhost:8080/health
```
Expected response:
```json
{
"status": "healthy",
"service": "notifier",
"time": "2025-10-16T21:05:27Z"
}
```
### 2. Send a Simple Notification
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "stdout",
"subject": "Hello World",
"body": "This is a test notification!",
"recipients": ["console"]
}'
```
Expected response:
```json
{
"result": {
"notification_id": "abc123...",
"success": true,
"message": "notification queued successfully",
"sent_at": "2025-10-16T21:05:27Z"
}
}
```
The notification will be printed to the server's stdout.
### 3. Send with Priority
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "stdout",
"priority": 4,
"subject": "CRITICAL Alert",
"body": "This is a critical notification!",
"recipients": ["console"],
"max_retries": 5
}'
```
Priority levels:
- `0` - Low
- `1` - Normal (default)
- `2` - High
- `3` - Critical
### 4. Send Batch Notifications
```bash
curl -X POST http://localhost:8080/api/v1/notifications/batch \
-H "Content-Type: application/json" \
-d '{
"notifications": [
{
"type": "stdout",
"subject": "Notification 1",
"body": "First notification",
"recipients": ["console"]
},
{
"type": "stdout",
"subject": "Notification 2",
"body": "Second notification",
"recipients": ["console"]
}
]
}'
```
### 5. Get Notification Statistics
```bash
curl http://localhost:8080/api/v1/stats
```
Expected response:
```json
{
"total_sent": 5,
"total_failed": 0,
"total_pending": 0,
"total_queued": 0,
"by_type": {
"stdout": 5
},
"by_status": {
"sent": 5
},
"average_latency_ms": 0
}
```
### 6. List Notifications
```bash
# List all notifications
curl http://localhost:8080/api/v1/notifications
# List with filters
curl "http://localhost:8080/api/v1/notifications?type=stdout&status=sent&limit=10"
```
### 7. Get Specific Notification
```bash
curl http://localhost:8080/api/v1/notifications/{notification-id}
```
## Testing with Other Notifiers
### SMTP (Email)
Update `config.yaml`:
```yaml
notifiers:
smtp:
host: "smtp.gmail.com"
port: 587
username: "your-email@gmail.com"
password: "your-app-password"
from: "notifications@yourservice.com"
use_tls: true
```
Then send:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "email",
"subject": "Test Email",
"body": "This is a test email!",
"recipients": ["recipient@example.com"]
}'
```
### Slack
Update `config.yaml`:
```yaml
notifiers:
slack:
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
username: "Notifier Bot"
icon_emoji: ":bell:"
```
Then send:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "slack",
"subject": "Deployment Alert",
"body": "Application deployed successfully to production!",
"recipients": ["#alerts"]
}'
```
### Ntfy
Update `config.yaml`:
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.sh"
```
Then send:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Mobile Alert",
"body": "This will appear on your phone!",
"recipients": ["mytopic"],
"metadata": {
"tags": ["warning", "skull"]
}
}'
```
## Configuration
### Using Environment Variables
```bash
export NOTIFIER_SERVER_REST_PORT=9000
export NOTIFIER_QUEUE_WORKER_COUNT=20
export NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com
export NOTIFIER_NOTIFIERS_SMTP_PASSWORD=secret
./bin/restserver
```
### Using config.yaml
Create or modify `config.yaml` in the project root:
```yaml
server:
rest_port: 8080
host: "0.0.0.0"
queue:
type: "local"
worker_count: 10
notifiers:
stdout: true
```
## Docker Deployment
### Build Docker Image
```bash
docker build -t notifier:latest .
```
### Run with Docker
```bash
docker run -p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
notifier:latest
```
### Using Docker Compose
```bash
docker-compose up
```
## Kubernetes Deployment
### Deploy to Kubernetes
```bash
# Apply all resources
kubectl apply -f k8s/
# Check status
kubectl get pods -l app=notifier
kubectl get svc -l app=notifier
# View logs
kubectl logs -f -l app=notifier
# Port forward to access locally
kubectl port-forward svc/notifier-rest 8080:8080
```
## Next Steps
1. **Add Authentication**: Implement API key or OAuth authentication
2. **Add Email Templates**: Support for templated notifications
3. **Add Webhooks**: Get callbacks when notifications are sent/failed
4. **Add Persistence**: Store notifications in a database
5. **Add Kafka Queue**: For distributed processing
6. **Add Metrics**: Instrument with Prometheus metrics
7. **Add Tests**: Write unit and integration tests
## Troubleshooting
### Server won't start
- Check if port 8080 is already in use: `lsof -i :8080`
- Check config.yaml syntax
- Verify all dependencies are installed: `go mod tidy`
### Notifications not sending
- Check server logs for errors
- Verify the notifier is enabled in config.yaml
- For SMTP: Verify credentials and allow less secure apps
- For Slack: Verify webhook URL is correct
- For Ntfy: Ensure topic name is valid
### Queue filling up
- Increase worker count in config.yaml
- Check if notifiers are failing
- Review retry configuration
## API Reference
Full API documentation available in `ARCHITECTURE.md`.
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| POST | /api/v1/notifications | Send notification |
| POST | /api/v1/notifications/batch | Send batch |
| GET | /api/v1/notifications | List notifications |
| GET | /api/v1/notifications/{id} | Get notification |
| DELETE | /api/v1/notifications/{id} | Cancel notification |
| POST | /api/v1/notifications/{id}/retry | Retry notification |
| GET | /api/v1/stats | Get statistics |
+577 -107
View File
@@ -1,153 +1,623 @@
# Notifier Microservice
# Notifier Module
A production-ready notification delivery microservice that supports multiple notification channels (Email, Slack, Ntfy, Stdout) with both REST and gRPC APIs running simultaneously.
The **Notifier** module provides a flexible notification system that supports multiple notification modes (e.g., SMTP, SMS, Slack, Ntfy, and Stdout). It can operate as a standalone gRPC or REST microservice, or be used as an imported module in other projects.
[![Go Version](https://img.shields.io/badge/go-1.23.2-blue.svg)](https://golang.org)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
## Overview
Notifier is a cloud-native microservice designed to handle all your application's notification needs through a single, unified API. Send emails, Slack messages, push notifications, and more with a consistent interface and robust delivery guarantees.
Perfect for:
- Microservices architectures needing centralized notifications
- Applications requiring multiple notification channels
- Systems needing reliable async notification delivery
- Teams wanting to standardize notification handling
## Features
- **Multi-Mode Notification**: Supports different notification methods including SMTP email, SMS, Slack, Ntfy, and standard output.
- **Extensible Design**: Add new notification modes by implementing the `Notifier` interface.
- **gRPC and REST APIs**: Accessible through both gRPC and RESTful APIs, allowing easy integration into various systems.
- **Configuration Management**: Easily configurable for different environments and API keys.
### Core Capabilities
- 🔔 **Multi-Channel Support**: Email (SMTP), Slack, Ntfy.sh, and Stdout
- 🚀 **Dual API**: REST and gRPC running simultaneously in one process
- 📦 **Queue-Based**: Async processing with configurable worker pools
- 🔄 **Retry Logic**: Exponential backoff with configurable attempts
-**Priority Levels**: Low, Normal, High, and Critical
- 📊 **Batch Operations**: Send multiple notifications efficiently
- 🎯 **Status Tracking**: Monitor notification lifecycle and delivery
### Production Features
- ⚙️ **Configuration**: Viper-based with environment variable support
- 🐳 **Containerized**: Multi-stage Docker builds with non-root user
- ☸️ **Kubernetes Ready**: Complete manifests with HPA, health checks, and RBAC
- 🔒 **Secure**: Token-based auth for ntfy, TLS support, secret management
- 📈 **Observable**: Health endpoints, metrics support, structured logging
- 🔌 **Extensible**: Clean interfaces for adding new notifiers
## Quick Start
### 1. Install and Run
```bash
# Clone and install dependencies
git clone https://github.com/igodwin/notifier
cd notifier
go mod tidy
# Build and run
go build -o bin/server ./cmd/server
./bin/server
```
Server starts with:
- **REST API** on `http://localhost:8080`
- **gRPC API** on `localhost:50051`
- **Stdout notifier** enabled by default
### 2. Send Your First Notification
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "stdout",
"subject": "Hello from Notifier!",
"body": "Your first notification",
"recipients": ["console"]
}'
```
The notification appears immediately in the server output. ✅
### 3. Check Status
```bash
# Health check
curl http://localhost:8080/health
# Statistics
curl http://localhost:8080/api/v1/stats
```
## Configuration
### Basic Setup
Create `config.yaml` in the project root:
```yaml
server:
mode: "both" # Options: both, rest, grpc
rest_port: 8080
grpc_port: 50051
host: "0.0.0.0"
queue:
type: "local" # Local in-memory queue
worker_count: 10 # Concurrent workers
retry_attempts: 3
retry_backoff: "exponential"
notifiers:
stdout: true # Always enabled for testing
```
### Email Notifications (SMTP)
```yaml
notifiers:
smtp:
host: "smtp.gmail.com"
port: 587
username: "your-email@gmail.com"
password: "your-app-password"
from: "notifications@yourservice.com"
use_tls: true
```
**Usage:**
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "email",
"subject": "Welcome!",
"body": "Thanks for signing up",
"recipients": ["user@example.com"]
}'
```
### Slack Notifications
```yaml
notifiers:
slack:
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
username: "Notifier Bot"
icon_emoji: ":bell:"
```
**Usage:**
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "slack",
"subject": "Deployment Complete",
"body": "v2.0 deployed to production",
"recipients": ["#alerts"]
}'
```
### Ntfy Push Notifications
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.sh"
token: "tk_your_access_token" # Optional, for private topics
default_topic: "myapp-alerts"
```
**Usage:**
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"priority": 3,
"subject": "Critical Alert",
"body": "Server CPU at 95%",
"recipients": ["alerts"],
"metadata": {
"tags": ["warning", "rotating_light"],
"click": "https://dashboard.example.com"
}
}'
```
See [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) for advanced ntfy features (action buttons, attachments, delays, etc.).
### Environment Variables
Override any config with environment variables:
```bash
export NOTIFIER_SERVER_MODE=both
export NOTIFIER_SERVER_REST_PORT=8080
export NOTIFIER_QUEUE_WORKER_COUNT=20
export NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com
export NOTIFIER_NOTIFIERS_SMTP_PASSWORD=secret
export NOTIFIER_NOTIFIERS_NTFY_TOKEN=tk_your_token
```
Format: `NOTIFIER_<SECTION>_<KEY>` (use `_` for nested keys)
## API Reference
### REST Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/health` | Health check |
| `POST` | `/api/v1/notifications` | Send single notification |
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications |
| `GET` | `/api/v1/notifications` | List notifications (with filters) |
| `GET` | `/api/v1/notifications/{id}` | Get notification by ID |
| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification |
| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed notification |
| `GET` | `/api/v1/stats` | Get service statistics |
### Request Format
```json
{
"type": "email|slack|ntfy|stdout",
"priority": 0-3,
"subject": "Notification title",
"body": "Notification body",
"recipients": ["email@example.com", "#channel", "topic"],
"metadata": {
"key": "value"
},
"max_retries": 3
}
```
### Response Format
```json
{
"result": {
"notification_id": "uuid",
"success": true,
"message": "notification queued successfully",
"sent_at": "2025-10-16T21:05:27Z"
}
}
```
### Priority Levels
- `0` - Low (background notifications)
- `1` - Normal (default)
- `2` - High (important updates)
- `3` - Critical (urgent alerts)
### Batch Operations
```bash
curl -X POST http://localhost:8080/api/v1/notifications/batch \
-H "Content-Type: application/json" \
-d '{
"notifications": [
{"type": "email", "subject": "Welcome", ...},
{"type": "slack", "subject": "Alert", ...}
]
}'
```
### Filtering Notifications
```bash
# Get all sent email notifications
curl "http://localhost:8080/api/v1/notifications?type=email&status=sent&limit=10"
# Get recent failures
curl "http://localhost:8080/api/v1/notifications?status=failed&limit=20"
```
## gRPC API
The gRPC service mirrors the REST API with full feature parity. See [api/grpc/notifier.proto](api/grpc/notifier.proto) for definitions.
**Generate Go code:**
```bash
make proto-gen
# or
protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto
```
**Note:** gRPC server is running but handler implementation is pending. Protobuf definitions are complete.
## Deployment
### Docker
**Build:**
```bash
docker build -t notifier:latest .
```
**Run:**
```bash
docker run -d \
--name notifier \
-p 8080:8080 \
-p 50051:50051 \
-v $(pwd)/config.yaml:/app/config.yaml:ro \
notifier:latest
```
**Docker Compose:**
```bash
docker-compose up -d
```
Includes optional services: Kafka, Prometheus, Grafana (commented out by default)
### Kubernetes
**Deploy:**
```bash
kubectl apply -f k8s/
```
**Included resources:**
- Deployment (3 replicas with rolling updates)
- Services (separate for REST and gRPC)
- ConfigMap (configuration management)
- HPA (auto-scaling 3-10 pods based on CPU/memory)
- Ingress (external access with TLS)
- Secret template (for credentials)
**Access locally:**
```bash
kubectl port-forward svc/notifier-rest 8080:8080
kubectl port-forward svc/notifier-grpc 50051:50051
```
**Using Kustomize:**
```bash
kubectl apply -k k8s/
```
## Architecture
### High-Level Design
```
┌──────────────┐ ┌──────────────┐
│ REST Client │────▶│ REST API │
└──────────────┘ │ (port 8080) │
└──────┬───────┘
┌──────────────┐ │
│ gRPC Client │────▶│ gRPC API │
└──────────────┘ │ (port 50051) │
└──────┬───────┘
┌──────────────────┐
│ Notification │
│ Service │
└────────┬─────────┘
┌──────────────────┐
│ Queue (Local) │◀──┐
└────────┬─────────┘ │
▼ │
┌──────────────────┐ │
│ Worker Pool │───┘
│ (10 workers) │
└────────┬─────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ SMTP │ │ Slack │ │ Ntfy │
│Notifier│ │ Notifier │ │ Notifier │
└────────┘ └──────────┘ └──────────┘
```
### Key Components
- **API Layer**: REST (Gorilla mux) and gRPC (Protocol Buffers)
- **Service Layer**: Business logic, validation, orchestration
- **Queue**: In-memory with optional disk persistence (Kafka planned)
- **Workers**: Concurrent processors with retry logic
- **Notifiers**: Pluggable providers implementing `domain.Notifier`
- **Config**: Viper-based with file + env var support
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design documentation.
## Project Structure
```plaintext
```
notifier/
├── api/
│ ├── grpc/ # gRPC service definitions and generated code
│ └── rest/ # REST API handlers and router
│ ├── grpc/
│ └── notifier.proto # gRPC service definition
│ └── rest/
│ ├── handlers.go # HTTP handlers
│ ├── router.go # Route configuration
│ └── types.go # Request/response types
├── cmd/
│ ├── grpcserver/ # Entrypoint for running the gRPC server
── restserver/ # Entrypoint for running the REST server
│ ├── server/main.go # Combined server (recommended)
── grpcserver/main.go # gRPC only
│ └── restserver/main.go # REST only
├── internal/
│ ├── config/ # Configuration loading
│ └── notifier/ # Core notification logic with multiple implementations
├── pkg/
│ ├── grpcclient/ # gRPC client for interacting with the notifier service
└── restclient/ # REST client for interacting with the notifier service
├── Dockerfile # Docker configuration for deploying as a microservice
├── LICENSE # License file
├── README.md # Documentation
└── go.mod # Defines the module's dependencies, module path, and Go version
│ ├── config/
│ └── config.go # Configuration management
│ ├── domain/
│ ├── notification.go # Core types
│ ├── notifier.go # Notifier interface
│ │ └── queue.go # Queue interface
│ ├── notifier/
│ │ ├── notifier.go # Factory & base
│ │ ├── smtp.go # Email notifier
│ │ ├── slack.go # Slack notifier
│ │ ├── ntfy.go # Ntfy notifier
│ │ └── stdout.go # Stdout notifier
│ ├── queue/
│ │ └── local.go # In-memory queue
│ └── service/
│ └── service.go # Business logic
├── k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ └── kustomization.yaml
├── docs/
│ └── NTFY_GUIDE.md # Ntfy integration guide
├── config.yaml # Default configuration
├── docker-compose.yaml
├── Dockerfile
├── Makefile
├── QUICKSTART.md # API examples
├── ARCHITECTURE.md # Design documentation
└── README.md # This file
```
## Getting Started
## Development
### Prerequisites
- **Go**: Install Go 1.18 or higher.
- **Protocol Buffers**: Required if you want to regenerate gRPC code.
- **Docker** (optional): For containerized deployment.
### Installation
To install the notifier module as a dependency in another Go project, run:
### Build Commands
```bash
go get github.com/igodwin/notifier
make build # Build binaries
make run-rest # Run REST server
make run-grpc # Run gRPC server (when implemented)
make test # Run tests
make lint # Run linter
make proto-gen # Generate protobuf code
make docker-build # Build Docker image
make clean # Clean artifacts
```
### Running the Service
### Adding a New Notifier
#### gRPC Server
To start the gRPC server:
```bash
cd cmd/grpcserver
go run main.go
```
#### REST Server
To start the REST server:
```bash
cd cmd/restserver
go run main.go
```
### Configuration
Configure notification modes via environment variables or a configuration file (e.g., `config.yaml`). Configuration settings may include:
- **SMTP**: SMTP server details, port, credentials.
- **SMS**: SMS provider API keys.
- **Slack**: Slack webhook URLs.
- **Ntfy**: Ntfy service URL and options.
For example, using a `config.yaml`:
```yaml
smtp:
server: "smtp.example.com"
port: 587
username: "user@example.com"
password: "password"
slack:
webhook_url: "https://hooks.slack.com/services/..."
```
### Usage
#### Using gRPC API
1. Define a gRPC client using the `pkg/grpcclient` package.
2. Call `SendNotification` to send a message through a chosen notification method.
1. Create `internal/notifier/mynotifier.go`
2. Implement `domain.Notifier` interface
3. Add config struct to `internal/config/config.go`
4. Register in `cmd/server/main.go`
5. Update `config.yaml` with example config
6. Add tests
Example:
```go
client, err := grpcclient.NewClient("localhost:50051")
if err != nil {
log.Fatalf("Failed to create gRPC client: %v", err)
type MyNotifier struct {
BaseNotifier
config *MyNotifierConfig
}
resp, err := client.SendNotification("recipient@example.com", "Hello via gRPC!")
if err != nil {
log.Fatalf("Failed to send notification: %v", err)
func (m *MyNotifier) Send(ctx context.Context, n *domain.Notification) (*domain.NotificationResult, error) {
// Implementation
}
```
#### Using REST API
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed guide.
1. Define a REST client using the `pkg/restclient` package.
2. Call `SendNotification` via REST.
## Documentation
Example:
- [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples
- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details
- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration guide
- [api/grpc/notifier.proto](api/grpc/notifier.proto) - gRPC API definition
```go
client := restclient.NewClient("http://localhost:8080")
err := client.SendNotification("recipient@example.com", "Hello via REST!")
if err != nil {
log.Fatalf("Failed to send notification: %v", err)
## Testing
```bash
# Run all tests
go test ./...
# Run with coverage
go test -cover ./...
# Run specific package
go test ./internal/notifier/...
```
**Current Status:** Core implementation complete, tests pending.
## Monitoring & Operations
### Health Checks
```bash
curl http://localhost:8080/health
```
Returns:
```json
{
"status": "healthy",
"service": "notifier",
"time": "2025-10-16T21:05:27Z"
}
```
### Implementing New Notification Modes
### Statistics
To add a new notification method, implement the `Notifier` interface in `internal/notifier/`:
```bash
curl http://localhost:8080/api/v1/stats
```
```go
type Notifier interface {
Send(notification Notification) error
Returns:
```json
{
"total_sent": 1234,
"total_failed": 5,
"total_pending": 0,
"total_queued": 2,
"by_type": {
"email": 800,
"slack": 400,
"ntfy": 34
},
"by_status": {
"sent": 1234,
"failed": 5
}
}
```
Add the new mode (e.g., `sms.go`) with the `Send` method to implement custom logic.
### Graceful Shutdown
The server handles `SIGINT` and `SIGTERM` gracefully:
- Stops accepting new requests
- Completes in-flight notifications
- Drains the queue
- Closes all connections
- 30-second timeout
## Roadmap
### Completed ✅
- [x] Core notification system
- [x] REST API (fully functional)
- [x] gRPC API (protobuf defined)
- [x] Local queue with workers
- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout)
- [x] Priority and retry logic
- [x] Batch operations
- [x] Docker support
- [x] Kubernetes manifests with HPA
- [x] Health checks and stats
- [x] Configuration management
### Planned 🚧
- [ ] gRPC handler implementation
- [ ] Kafka queue adapter
- [ ] Database persistence (PostgreSQL)
- [ ] Notification templates
- [ ] Webhook callbacks
- [ ] Authentication/Authorization (API keys, OAuth)
- [ ] Rate limiting (per client, per notifier)
- [ ] Prometheus metrics
- [ ] OpenTelemetry tracing
- [ ] Comprehensive test suite
- [ ] Circuit breakers for notifiers
- [ ] Dead letter queue
- [ ] Admin dashboard
## Contributing
Contributions are welcome! To contribute, fork the repository, make your changes, and submit a pull request.
Contributions are welcome! Please:
1. Fork the repository.
2. Create a feature branch (`git checkout -b feature/NewMode`).
3. Commit your changes (`git commit -am 'Add new notification mode'`).
4. Push to the branch (`git push origin feature/NewMode`).
5. Create a new Pull Request.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Write tests for your changes
4. Ensure tests pass (`go test ./...`)
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
Please follow Go best practices and maintain test coverage.
## Security
### Reporting Vulnerabilities
Please report security vulnerabilities privately via GitHub Security Advisories.
### Best Practices
- Store credentials in environment variables or secrets managers
- Use TLS for production deployments
- Enable authentication for ntfy private topics
- Rotate tokens regularly
- Follow principle of least privilege for K8s RBAC
- Keep dependencies updated
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
## Support & Community
- **Issues**: [GitHub Issues](https://github.com/igodwin/notifier/issues)
- **Discussions**: [GitHub Discussions](https://github.com/igodwin/notifier/discussions)
- **Documentation**: See `docs/` directory
- **Examples**: See [QUICKSTART.md](QUICKSTART.md)
## Acknowledgments
Built with:
- [Gorilla Mux](https://github.com/gorilla/mux) - HTTP routing
- [Viper](https://github.com/spf13/viper) - Configuration
- [gRPC-Go](https://github.com/grpc/grpc-go) - RPC framework
- [Ntfy](https://ntfy.sh) - Push notifications
---
**Made with ❤️ for reliable notifications**
+196
View File
@@ -0,0 +1,196 @@
syntax = "proto3";
package notifier.v1;
option go_package = "github.com/igodwin/notifier/api/grpc/pb";
import "google/protobuf/timestamp.proto";
// NotifierService handles notification operations
service NotifierService {
// SendNotification sends a single notification
rpc SendNotification(SendNotificationRequest) returns (SendNotificationResponse);
// SendBatchNotifications sends multiple notifications
rpc SendBatchNotifications(SendBatchNotificationsRequest) returns (SendBatchNotificationsResponse);
// GetNotification retrieves a notification by ID
rpc GetNotification(GetNotificationRequest) returns (GetNotificationResponse);
// ListNotifications retrieves notifications matching a filter
rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);
// CancelNotification cancels a pending notification
rpc CancelNotification(CancelNotificationRequest) returns (CancelNotificationResponse);
// RetryNotification retries a failed notification
rpc RetryNotification(RetryNotificationRequest) returns (RetryNotificationResponse);
// GetStats returns notification statistics
rpc GetStats(GetStatsRequest) returns (GetStatsResponse);
// HealthCheck verifies the service is operational
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
}
// NotificationType defines the channel for notification delivery
enum NotificationType {
NOTIFICATION_TYPE_UNSPECIFIED = 0;
NOTIFICATION_TYPE_EMAIL = 1;
NOTIFICATION_TYPE_SLACK = 2;
NOTIFICATION_TYPE_NTFY = 3;
NOTIFICATION_TYPE_STDOUT = 4;
}
// Priority defines the urgency level
enum Priority {
PRIORITY_UNSPECIFIED = 0;
PRIORITY_LOW = 1;
PRIORITY_NORMAL = 2;
PRIORITY_HIGH = 3;
PRIORITY_CRITICAL = 4;
}
// NotificationStatus represents the state of a notification
enum NotificationStatus {
NOTIFICATION_STATUS_UNSPECIFIED = 0;
NOTIFICATION_STATUS_PENDING = 1;
NOTIFICATION_STATUS_QUEUED = 2;
NOTIFICATION_STATUS_PROCESSING = 3;
NOTIFICATION_STATUS_SENT = 4;
NOTIFICATION_STATUS_FAILED = 5;
NOTIFICATION_STATUS_RETRYING = 6;
}
// Notification represents a notification message
message Notification {
string id = 1;
NotificationType type = 2;
Priority priority = 3;
NotificationStatus status = 4;
string subject = 5;
string body = 6;
repeated string recipients = 7;
map<string, string> metadata = 8;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp scheduled_for = 10;
google.protobuf.Timestamp sent_at = 11;
int32 retry_count = 12;
int32 max_retries = 13;
string last_error = 14;
}
// NotificationResult represents the outcome of sending a notification
message NotificationResult {
string notification_id = 1;
bool success = 2;
string message = 3;
string error = 4;
google.protobuf.Timestamp sent_at = 5;
map<string, string> provider_response = 6;
}
// SendNotificationRequest sends a single notification
message SendNotificationRequest {
NotificationType type = 1;
Priority priority = 2;
string subject = 3;
string body = 4;
repeated string recipients = 5;
map<string, string> metadata = 6;
google.protobuf.Timestamp scheduled_for = 7;
int32 max_retries = 8;
}
// SendNotificationResponse returns the result of sending a notification
message SendNotificationResponse {
NotificationResult result = 1;
}
// SendBatchNotificationsRequest sends multiple notifications
message SendBatchNotificationsRequest {
repeated SendNotificationRequest notifications = 1;
}
// SendBatchNotificationsResponse returns the results of sending multiple notifications
message SendBatchNotificationsResponse {
repeated NotificationResult results = 1;
}
// GetNotificationRequest retrieves a notification by ID
message GetNotificationRequest {
string id = 1;
}
// GetNotificationResponse returns a notification
message GetNotificationResponse {
Notification notification = 1;
}
// NotificationFilter is used for querying notifications
message NotificationFilter {
repeated string ids = 1;
repeated NotificationType types = 2;
repeated NotificationStatus statuses = 3;
repeated string recipients = 4;
google.protobuf.Timestamp created_after = 5;
google.protobuf.Timestamp created_before = 6;
int32 limit = 7;
int32 offset = 8;
}
// ListNotificationsRequest retrieves notifications matching a filter
message ListNotificationsRequest {
NotificationFilter filter = 1;
}
// ListNotificationsResponse returns a list of notifications
message ListNotificationsResponse {
repeated Notification notifications = 1;
int64 total = 2;
}
// CancelNotificationRequest cancels a pending notification
message CancelNotificationRequest {
string id = 1;
}
// CancelNotificationResponse returns the result of canceling a notification
message CancelNotificationResponse {
bool success = 1;
string message = 2;
}
// RetryNotificationRequest retries a failed notification
message RetryNotificationRequest {
string id = 1;
}
// RetryNotificationResponse returns the result of retrying a notification
message RetryNotificationResponse {
NotificationResult result = 1;
}
// GetStatsRequest requests notification statistics
message GetStatsRequest {}
// GetStatsResponse returns notification statistics
message GetStatsResponse {
int64 total_sent = 1;
int64 total_failed = 2;
int64 total_pending = 3;
int64 total_queued = 4;
map<string, int64> by_type = 5;
map<string, int64> by_status = 6;
double average_latency_ms = 7;
}
// HealthCheckRequest requests health status
message HealthCheckRequest {}
// HealthCheckResponse returns health status
message HealthCheckResponse {
bool healthy = 1;
string status = 2;
map<string, string> components = 3;
}
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.0
// source: api/grpc/notifier.proto
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
NotifierService_SendNotification_FullMethodName = "/notifier.v1.NotifierService/SendNotification"
NotifierService_SendBatchNotifications_FullMethodName = "/notifier.v1.NotifierService/SendBatchNotifications"
NotifierService_GetNotification_FullMethodName = "/notifier.v1.NotifierService/GetNotification"
NotifierService_ListNotifications_FullMethodName = "/notifier.v1.NotifierService/ListNotifications"
NotifierService_CancelNotification_FullMethodName = "/notifier.v1.NotifierService/CancelNotification"
NotifierService_RetryNotification_FullMethodName = "/notifier.v1.NotifierService/RetryNotification"
NotifierService_GetStats_FullMethodName = "/notifier.v1.NotifierService/GetStats"
NotifierService_HealthCheck_FullMethodName = "/notifier.v1.NotifierService/HealthCheck"
)
// NotifierServiceClient is the client API for NotifierService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// NotifierService handles notification operations
type NotifierServiceClient interface {
// SendNotification sends a single notification
SendNotification(ctx context.Context, in *SendNotificationRequest, opts ...grpc.CallOption) (*SendNotificationResponse, error)
// SendBatchNotifications sends multiple notifications
SendBatchNotifications(ctx context.Context, in *SendBatchNotificationsRequest, opts ...grpc.CallOption) (*SendBatchNotificationsResponse, error)
// GetNotification retrieves a notification by ID
GetNotification(ctx context.Context, in *GetNotificationRequest, opts ...grpc.CallOption) (*GetNotificationResponse, error)
// ListNotifications retrieves notifications matching a filter
ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*ListNotificationsResponse, error)
// CancelNotification cancels a pending notification
CancelNotification(ctx context.Context, in *CancelNotificationRequest, opts ...grpc.CallOption) (*CancelNotificationResponse, error)
// RetryNotification retries a failed notification
RetryNotification(ctx context.Context, in *RetryNotificationRequest, opts ...grpc.CallOption) (*RetryNotificationResponse, error)
// GetStats returns notification statistics
GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error)
// HealthCheck verifies the service is operational
HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error)
}
type notifierServiceClient struct {
cc grpc.ClientConnInterface
}
func NewNotifierServiceClient(cc grpc.ClientConnInterface) NotifierServiceClient {
return &notifierServiceClient{cc}
}
func (c *notifierServiceClient) SendNotification(ctx context.Context, in *SendNotificationRequest, opts ...grpc.CallOption) (*SendNotificationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SendNotificationResponse)
err := c.cc.Invoke(ctx, NotifierService_SendNotification_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) SendBatchNotifications(ctx context.Context, in *SendBatchNotificationsRequest, opts ...grpc.CallOption) (*SendBatchNotificationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SendBatchNotificationsResponse)
err := c.cc.Invoke(ctx, NotifierService_SendBatchNotifications_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) GetNotification(ctx context.Context, in *GetNotificationRequest, opts ...grpc.CallOption) (*GetNotificationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetNotificationResponse)
err := c.cc.Invoke(ctx, NotifierService_GetNotification_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*ListNotificationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListNotificationsResponse)
err := c.cc.Invoke(ctx, NotifierService_ListNotifications_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) CancelNotification(ctx context.Context, in *CancelNotificationRequest, opts ...grpc.CallOption) (*CancelNotificationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CancelNotificationResponse)
err := c.cc.Invoke(ctx, NotifierService_CancelNotification_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) RetryNotification(ctx context.Context, in *RetryNotificationRequest, opts ...grpc.CallOption) (*RetryNotificationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RetryNotificationResponse)
err := c.cc.Invoke(ctx, NotifierService_RetryNotification_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetStatsResponse)
err := c.cc.Invoke(ctx, NotifierService_GetStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *notifierServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthCheckResponse)
err := c.cc.Invoke(ctx, NotifierService_HealthCheck_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// NotifierServiceServer is the server API for NotifierService service.
// All implementations must embed UnimplementedNotifierServiceServer
// for forward compatibility.
//
// NotifierService handles notification operations
type NotifierServiceServer interface {
// SendNotification sends a single notification
SendNotification(context.Context, *SendNotificationRequest) (*SendNotificationResponse, error)
// SendBatchNotifications sends multiple notifications
SendBatchNotifications(context.Context, *SendBatchNotificationsRequest) (*SendBatchNotificationsResponse, error)
// GetNotification retrieves a notification by ID
GetNotification(context.Context, *GetNotificationRequest) (*GetNotificationResponse, error)
// ListNotifications retrieves notifications matching a filter
ListNotifications(context.Context, *ListNotificationsRequest) (*ListNotificationsResponse, error)
// CancelNotification cancels a pending notification
CancelNotification(context.Context, *CancelNotificationRequest) (*CancelNotificationResponse, error)
// RetryNotification retries a failed notification
RetryNotification(context.Context, *RetryNotificationRequest) (*RetryNotificationResponse, error)
// GetStats returns notification statistics
GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error)
// HealthCheck verifies the service is operational
HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error)
mustEmbedUnimplementedNotifierServiceServer()
}
// UnimplementedNotifierServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedNotifierServiceServer struct{}
func (UnimplementedNotifierServiceServer) SendNotification(context.Context, *SendNotificationRequest) (*SendNotificationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendNotification not implemented")
}
func (UnimplementedNotifierServiceServer) SendBatchNotifications(context.Context, *SendBatchNotificationsRequest) (*SendBatchNotificationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendBatchNotifications not implemented")
}
func (UnimplementedNotifierServiceServer) GetNotification(context.Context, *GetNotificationRequest) (*GetNotificationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetNotification not implemented")
}
func (UnimplementedNotifierServiceServer) ListNotifications(context.Context, *ListNotificationsRequest) (*ListNotificationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListNotifications not implemented")
}
func (UnimplementedNotifierServiceServer) CancelNotification(context.Context, *CancelNotificationRequest) (*CancelNotificationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CancelNotification not implemented")
}
func (UnimplementedNotifierServiceServer) RetryNotification(context.Context, *RetryNotificationRequest) (*RetryNotificationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RetryNotification not implemented")
}
func (UnimplementedNotifierServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented")
}
func (UnimplementedNotifierServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method HealthCheck not implemented")
}
func (UnimplementedNotifierServiceServer) mustEmbedUnimplementedNotifierServiceServer() {}
func (UnimplementedNotifierServiceServer) testEmbeddedByValue() {}
// UnsafeNotifierServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to NotifierServiceServer will
// result in compilation errors.
type UnsafeNotifierServiceServer interface {
mustEmbedUnimplementedNotifierServiceServer()
}
func RegisterNotifierServiceServer(s grpc.ServiceRegistrar, srv NotifierServiceServer) {
// If the following call pancis, it indicates UnimplementedNotifierServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&NotifierService_ServiceDesc, srv)
}
func _NotifierService_SendNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendNotificationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).SendNotification(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_SendNotification_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).SendNotification(ctx, req.(*SendNotificationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_SendBatchNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendBatchNotificationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).SendBatchNotifications(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_SendBatchNotifications_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).SendBatchNotifications(ctx, req.(*SendBatchNotificationsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_GetNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetNotificationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).GetNotification(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_GetNotification_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).GetNotification(ctx, req.(*GetNotificationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_ListNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListNotificationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).ListNotifications(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_ListNotifications_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).ListNotifications(ctx, req.(*ListNotificationsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_CancelNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CancelNotificationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).CancelNotification(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_CancelNotification_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).CancelNotification(ctx, req.(*CancelNotificationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_RetryNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RetryNotificationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).RetryNotification(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_RetryNotification_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).RetryNotification(ctx, req.(*RetryNotificationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetStatsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).GetStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_GetStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).GetStats(ctx, req.(*GetStatsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _NotifierService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthCheckRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NotifierServiceServer).HealthCheck(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: NotifierService_HealthCheck_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NotifierServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest))
}
return interceptor(ctx, in, info, handler)
}
// NotifierService_ServiceDesc is the grpc.ServiceDesc for NotifierService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var NotifierService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "notifier.v1.NotifierService",
HandlerType: (*NotifierServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "SendNotification",
Handler: _NotifierService_SendNotification_Handler,
},
{
MethodName: "SendBatchNotifications",
Handler: _NotifierService_SendBatchNotifications_Handler,
},
{
MethodName: "GetNotification",
Handler: _NotifierService_GetNotification_Handler,
},
{
MethodName: "ListNotifications",
Handler: _NotifierService_ListNotifications_Handler,
},
{
MethodName: "CancelNotification",
Handler: _NotifierService_CancelNotification_Handler,
},
{
MethodName: "RetryNotification",
Handler: _NotifierService_RetryNotification_Handler,
},
{
MethodName: "GetStats",
Handler: _NotifierService_GetStats_Handler,
},
{
MethodName: "HealthCheck",
Handler: _NotifierService_HealthCheck_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/grpc/notifier.proto",
}
+239
View File
@@ -0,0 +1,239 @@
package rest
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/igodwin/notifier/internal/domain"
)
// Handler handles REST API requests
type Handler struct {
service domain.NotificationService
}
// NewHandler creates a new REST handler
func NewHandler(service domain.NotificationService) *Handler {
return &Handler{
service: service,
}
}
// SendNotification handles POST /api/v1/notifications
func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) {
var req SendNotificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body", err)
return
}
// Validate request
if err := req.Validate(); err != nil {
respondError(w, http.StatusBadRequest, "validation failed", err)
return
}
// Convert to domain notification
notification := req.ToNotification()
// Send notification
result, err := h.service.Send(r.Context(), notification)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to send notification", err)
return
}
respondJSON(w, http.StatusAccepted, SendNotificationResponse{
Result: NotificationResultFromDomain(result),
})
}
// SendBatchNotifications handles POST /api/v1/notifications/batch
func (h *Handler) SendBatchNotifications(w http.ResponseWriter, r *http.Request) {
var req SendBatchNotificationsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body", err)
return
}
// Validate and convert to domain notifications
notifications := make([]*domain.Notification, 0, len(req.Notifications))
for _, notifReq := range req.Notifications {
if err := notifReq.Validate(); err != nil {
respondError(w, http.StatusBadRequest, "validation failed", err)
return
}
notifications = append(notifications, notifReq.ToNotification())
}
// Send batch
results, err := h.service.SendBatch(r.Context(), notifications)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to send batch notifications", err)
return
}
// Convert results
apiResults := make([]NotificationResult, 0, len(results))
for _, result := range results {
apiResults = append(apiResults, NotificationResultFromDomain(result))
}
respondJSON(w, http.StatusAccepted, SendBatchNotificationsResponse{
Results: apiResults,
})
}
// GetNotification handles GET /api/v1/notifications/{id}
func (h *Handler) GetNotification(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
notification, err := h.service.GetNotification(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "notification not found", err)
return
}
respondJSON(w, http.StatusOK, NotificationFromDomain(notification))
}
// ListNotifications handles GET /api/v1/notifications
func (h *Handler) ListNotifications(w http.ResponseWriter, r *http.Request) {
filter := parseNotificationFilter(r)
notifications, err := h.service.ListNotifications(r.Context(), filter)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list notifications", err)
return
}
// Convert to API format
apiNotifications := make([]Notification, 0, len(notifications))
for _, notif := range notifications {
apiNotifications = append(apiNotifications, NotificationFromDomain(notif))
}
respondJSON(w, http.StatusOK, ListNotificationsResponse{
Notifications: apiNotifications,
Total: int64(len(apiNotifications)),
})
}
// CancelNotification handles DELETE /api/v1/notifications/{id}
func (h *Handler) CancelNotification(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
if err := h.service.CancelNotification(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to cancel notification", err)
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "notification canceled successfully",
})
}
// RetryNotification handles POST /api/v1/notifications/{id}/retry
func (h *Handler) RetryNotification(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
result, err := h.service.RetryNotification(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to retry notification", err)
return
}
respondJSON(w, http.StatusOK, RetryNotificationResponse{
Result: NotificationResultFromDomain(result),
})
}
// GetStats handles GET /api/v1/stats
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.service.GetStats(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get stats", err)
return
}
respondJSON(w, http.StatusOK, stats)
}
// HealthCheck handles GET /health
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]interface{}{
"status": "healthy",
"service": "notifier",
"time": time.Now().UTC(),
})
}
// parseNotificationFilter parses query parameters into a NotificationFilter
func parseNotificationFilter(r *http.Request) *domain.NotificationFilter {
query := r.URL.Query()
filter := &domain.NotificationFilter{}
// Parse limit
if limitStr := query.Get("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
filter.Limit = limit
}
}
// Parse offset
if offsetStr := query.Get("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil {
filter.Offset = offset
}
}
// Parse types
if types := query["type"]; len(types) > 0 {
for _, t := range types {
filter.Types = append(filter.Types, domain.NotificationType(t))
}
}
// Parse statuses
if statuses := query["status"]; len(statuses) > 0 {
for _, s := range statuses {
filter.Statuses = append(filter.Statuses, domain.NotificationStatus(s))
}
}
// Parse recipients
if recipients := query["recipient"]; len(recipients) > 0 {
filter.Recipients = recipients
}
return filter
}
// respondJSON sends a JSON response
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// respondError sends an error response
func respondError(w http.ResponseWriter, status int, message string, err error) {
errMsg := message
if err != nil {
errMsg = message + ": " + err.Error()
}
respondJSON(w, status, map[string]interface{}{
"error": message,
"details": errMsg,
})
}
+61
View File
@@ -0,0 +1,61 @@
package rest
import (
"net/http"
"github.com/gorilla/mux"
"github.com/igodwin/notifier/internal/domain"
)
// NewRouter creates a new HTTP router with all routes configured
func NewRouter(service domain.NotificationService) *mux.Router {
handler := NewHandler(service)
router := mux.NewRouter()
// API v1 routes
v1 := router.PathPrefix("/api/v1").Subrouter()
// Notification routes
v1.HandleFunc("/notifications", handler.SendNotification).Methods(http.MethodPost)
v1.HandleFunc("/notifications/batch", handler.SendBatchNotifications).Methods(http.MethodPost)
v1.HandleFunc("/notifications", handler.ListNotifications).Methods(http.MethodGet)
v1.HandleFunc("/notifications/{id}", handler.GetNotification).Methods(http.MethodGet)
v1.HandleFunc("/notifications/{id}", handler.CancelNotification).Methods(http.MethodDelete)
v1.HandleFunc("/notifications/{id}/retry", handler.RetryNotification).Methods(http.MethodPost)
// Stats route
v1.HandleFunc("/stats", handler.GetStats).Methods(http.MethodGet)
// Health check route
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
// Middleware
router.Use(loggingMiddleware)
router.Use(corsMiddleware)
return router
}
// loggingMiddleware logs incoming requests
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// You can add structured logging here
next.ServeHTTP(w, r)
})
}
// corsMiddleware adds CORS headers
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
+147
View File
@@ -0,0 +1,147 @@
package rest
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/igodwin/notifier/internal/domain"
)
// SendNotificationRequest is the REST API request for sending a notification
type SendNotificationRequest struct {
Type string `json:"type"`
Priority int `json:"priority,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
Recipients []string `json:"recipients"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
}
// Validate validates the request
func (r *SendNotificationRequest) Validate() error {
if r.Type == "" {
return fmt.Errorf("type is required")
}
if len(r.Recipients) == 0 {
return fmt.Errorf("at least one recipient is required")
}
if r.Body == "" {
return fmt.Errorf("body is required")
}
return nil
}
// ToNotification converts the request to a domain notification
func (r *SendNotificationRequest) ToNotification() *domain.Notification {
maxRetries := r.MaxRetries
if maxRetries == 0 {
maxRetries = 3 // Default
}
return &domain.Notification{
ID: uuid.New().String(),
Type: domain.NotificationType(r.Type),
Priority: domain.Priority(r.Priority),
Status: domain.StatusPending,
Subject: r.Subject,
Body: r.Body,
Recipients: r.Recipients,
Metadata: r.Metadata,
CreatedAt: time.Now(),
ScheduledFor: r.ScheduledFor,
MaxRetries: maxRetries,
RetryCount: 0,
}
}
// SendNotificationResponse is the REST API response for sending a notification
type SendNotificationResponse struct {
Result NotificationResult `json:"result"`
}
// SendBatchNotificationsRequest is the REST API request for sending multiple notifications
type SendBatchNotificationsRequest struct {
Notifications []SendNotificationRequest `json:"notifications"`
}
// SendBatchNotificationsResponse is the REST API response for sending multiple notifications
type SendBatchNotificationsResponse struct {
Results []NotificationResult `json:"results"`
}
// Notification represents a notification in the REST API
type Notification struct {
ID string `json:"id"`
Type string `json:"type"`
Priority int `json:"priority"`
Status string `json:"status"`
Subject string `json:"subject"`
Body string `json:"body"`
Recipients []string `json:"recipients"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
SentAt *time.Time `json:"sent_at,omitempty"`
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
LastError string `json:"last_error,omitempty"`
}
// NotificationFromDomain converts a domain notification to API format
func NotificationFromDomain(n *domain.Notification) Notification {
return Notification{
ID: n.ID,
Type: string(n.Type),
Priority: int(n.Priority),
Status: string(n.Status),
Subject: n.Subject,
Body: n.Body,
Recipients: n.Recipients,
Metadata: n.Metadata,
CreatedAt: n.CreatedAt,
ScheduledFor: n.ScheduledFor,
SentAt: n.SentAt,
RetryCount: n.RetryCount,
MaxRetries: n.MaxRetries,
LastError: n.LastError,
}
}
// NotificationResult represents the result of a notification operation
type NotificationResult struct {
NotificationID string `json:"notification_id"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
SentAt time.Time `json:"sent_at"`
ProviderResponse map[string]interface{} `json:"provider_response,omitempty"`
}
// NotificationResultFromDomain converts a domain result to API format
func NotificationResultFromDomain(r *domain.NotificationResult) NotificationResult {
return NotificationResult{
NotificationID: r.NotificationID,
Success: r.Success,
Message: r.Message,
Error: r.Error,
SentAt: r.SentAt,
ProviderResponse: r.ProviderResponse,
}
}
// ListNotificationsResponse is the REST API response for listing notifications
type ListNotificationsResponse struct {
Notifications []Notification `json:"notifications"`
Total int64 `json:"total"`
}
// RetryNotificationResponse is the REST API response for retrying a notification
type RetryNotificationResponse struct {
Result NotificationResult `json:"result"`
}
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/igodwin/notifier/api/rest"
"github.com/igodwin/notifier/internal/config"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/notifier"
"github.com/igodwin/notifier/internal/queue"
"github.com/igodwin/notifier/internal/service"
)
func main() {
// Load configuration
cfg, err := config.Load("")
if err != nil {
log.Printf("Warning: failed to load config, using defaults: %v", err)
cfg = getDefaultConfig()
}
log.Printf("Starting Notifier REST Server on port %d", cfg.Server.RESTPort)
// Create context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize queue
var q domain.Queue
if cfg.Queue.Type == "local" {
q, err = queue.NewLocalQueue(cfg.Queue.Local)
if err != nil {
log.Fatalf("Failed to create queue: %v", err)
}
log.Println("Using local queue")
} else {
log.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type)
}
// Initialize notifier factory
factory := notifier.NewFactory()
// Register notifiers based on configuration
if cfg.Notifiers.Stdout {
stdoutNotifier := notifier.NewStdoutNotifier()
if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil {
log.Fatalf("Failed to register stdout notifier: %v", err)
}
log.Println("Registered stdout notifier")
}
if cfg.Notifiers.SMTP != nil {
smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP)
if err != nil {
log.Printf("Warning: failed to create SMTP notifier: %v", err)
} else {
if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil {
log.Fatalf("Failed to register SMTP notifier: %v", err)
}
log.Println("Registered SMTP notifier")
}
}
if cfg.Notifiers.Slack != nil {
slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack)
if err != nil {
log.Printf("Warning: failed to create Slack notifier: %v", err)
} else {
if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil {
log.Fatalf("Failed to register Slack notifier: %v", err)
}
log.Println("Registered Slack notifier")
}
}
if cfg.Notifiers.Ntfy != nil {
ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy)
if err != nil {
log.Printf("Warning: failed to create Ntfy notifier: %v", err)
} else {
if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil {
log.Fatalf("Failed to register Ntfy notifier: %v", err)
}
log.Println("Registered Ntfy notifier")
}
}
// Check if any notifiers are registered
if len(factory.SupportedTypes()) == 0 {
log.Fatal("No notifiers configured. Please enable at least one notifier in config.yaml")
}
log.Printf("Supported notification types: %v", factory.SupportedTypes())
// Create notification service
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount)
// Start workers
if err := svc.Start(ctx); err != nil {
log.Fatalf("Failed to start service: %v", err)
}
log.Printf("Started %d worker(s)", cfg.Queue.WorkerCount)
// Create REST router
router := rest.NewRouter(svc)
// Create HTTP server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
server := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
log.Printf("REST server listening on %s", addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
log.Println("Shutting down server...")
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Error during server shutdown: %v", err)
}
// Stop service
if err := svc.Stop(); err != nil {
log.Printf("Error stopping service: %v", err)
}
log.Println("Server stopped")
}
// getDefaultConfig returns a minimal default configuration
func getDefaultConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{
RESTPort: 8080,
Host: "0.0.0.0",
},
Queue: domain.QueueConfig{
Type: "local",
WorkerCount: 5,
RetryAttempts: 3,
Local: &domain.LocalQueueConfig{
BufferSize: 1000,
},
},
Notifiers: config.NotifiersConfig{
Stdout: true,
},
}
}
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/igodwin/notifier/api/rest"
"github.com/igodwin/notifier/internal/config"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/notifier"
"github.com/igodwin/notifier/internal/queue"
"github.com/igodwin/notifier/internal/service"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
func main() {
// Load configuration
cfg, err := config.Load("")
if err != nil {
log.Printf("Warning: failed to load config, using defaults: %v", err)
cfg = getDefaultConfig()
}
log.Printf("Starting Notifier Service in mode: %s", cfg.Server.Mode)
// Create context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize queue
var q domain.Queue
if cfg.Queue.Type == "local" {
q, err = queue.NewLocalQueue(cfg.Queue.Local)
if err != nil {
log.Fatalf("Failed to create queue: %v", err)
}
log.Println("Using local queue")
} else {
log.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type)
}
// Initialize notifier factory and register notifiers
factory := notifier.NewFactory()
registerNotifiers(cfg, factory)
// Check if any notifiers are registered
if len(factory.SupportedTypes()) == 0 {
log.Fatal("No notifiers configured. Please enable at least one notifier in config.yaml")
}
log.Printf("Supported notification types: %v", factory.SupportedTypes())
// Create notification service
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount)
// Start workers
if err := svc.Start(ctx); err != nil {
log.Fatalf("Failed to start service: %v", err)
}
log.Printf("Started %d worker(s)", cfg.Queue.WorkerCount)
// Wait group for both servers
var wg sync.WaitGroup
// Start gRPC server if enabled
var grpcServer *grpc.Server
if cfg.Server.Mode == "both" || cfg.Server.Mode == "grpc" {
wg.Add(1)
grpcServer = startGRPCServer(ctx, &wg, cfg, svc)
}
// Start REST server if enabled
var restServer *http.Server
if cfg.Server.Mode == "both" || cfg.Server.Mode == "rest" {
wg.Add(1)
restServer = startRESTServer(ctx, &wg, cfg, svc)
}
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
log.Println("Shutting down servers...")
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
// Stop REST server
if restServer != nil {
if err := restServer.Shutdown(shutdownCtx); err != nil {
log.Printf("Error during REST server shutdown: %v", err)
}
}
// Stop gRPC server
if grpcServer != nil {
grpcServer.GracefulStop()
}
// Wait for servers to stop
wg.Wait()
// Stop service
if err := svc.Stop(); err != nil {
log.Printf("Error stopping service: %v", err)
}
log.Println("Servers stopped")
}
func registerNotifiers(cfg *config.Config, factory *notifier.Factory) {
if cfg.Notifiers.Stdout {
stdoutNotifier := notifier.NewStdoutNotifier()
if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil {
log.Fatalf("Failed to register stdout notifier: %v", err)
}
log.Println("Registered stdout notifier")
}
if cfg.Notifiers.SMTP != nil {
smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP)
if err != nil {
log.Printf("Warning: failed to create SMTP notifier: %v", err)
} else {
if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil {
log.Fatalf("Failed to register SMTP notifier: %v", err)
}
log.Println("Registered SMTP notifier")
}
}
if cfg.Notifiers.Slack != nil {
slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack)
if err != nil {
log.Printf("Warning: failed to create Slack notifier: %v", err)
} else {
if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil {
log.Fatalf("Failed to register Slack notifier: %v", err)
}
log.Println("Registered Slack notifier")
}
}
if cfg.Notifiers.Ntfy != nil {
ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy)
if err != nil {
log.Printf("Warning: failed to create Ntfy notifier: %v", err)
} else {
if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil {
log.Fatalf("Failed to register Ntfy notifier: %v", err)
}
log.Println("Registered Ntfy notifier")
}
}
}
func startGRPCServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService) *grpc.Server {
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.GRPCPort)
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("Failed to listen on %s: %v", addr, err)
}
grpcServer := grpc.NewServer()
// TODO: Register gRPC service implementation when protobuf is generated
// pb.RegisterNotifierServiceServer(grpcServer, grpcHandler)
// Enable reflection for tools like grpcurl
reflection.Register(grpcServer)
go func() {
defer wg.Done()
log.Printf("gRPC server listening on %s", addr)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve gRPC: %v", err)
}
}()
return grpcServer
}
func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService) *http.Server {
router := rest.NewRouter(svc)
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
server := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
defer wg.Done()
log.Printf("REST server listening on %s", addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start REST server: %v", err)
}
}()
return server
}
func getDefaultConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{
GRPCPort: 50051,
RESTPort: 8080,
Host: "0.0.0.0",
Mode: "both",
},
Queue: domain.QueueConfig{
Type: "local",
WorkerCount: 5,
RetryAttempts: 3,
Local: &domain.LocalQueueConfig{
BufferSize: 1000,
},
},
Notifiers: config.NotifiersConfig{
Stdout: true,
},
}
}
+91
View File
@@ -0,0 +1,91 @@
# Notifier Service Configuration
server:
grpc_port: 50051
rest_port: 8080
host: "0.0.0.0"
mode: "both" # Options: both, grpc, rest
queue:
type: "local" # Options: local, kafka
max_size: 10000
worker_count: 10
retry_attempts: 3
retry_backoff: "exponential" # Options: exponential, linear, fixed
# Local queue configuration
local:
buffer_size: 1000
persist_to_disk: false
persist_path: "/var/lib/notifier/queue.json"
# Kafka queue configuration (when type: kafka)
# kafka:
# brokers:
# - "localhost:9092"
# topic: "notifications"
# consumer_group: "notifier-service"
# partition_count: 10
# replication_factor: 3
# enable_idempotence: true
# compression_type: "snappy"
notifiers:
# Enable stdout notifier (useful for development/debugging)
stdout: true
# SMTP email configuration
smtp:
host: "smtp.gmail.com"
port: 587
username: "your-email@gmail.com"
password: "your-app-password"
from: "notifications@yourservice.com"
use_tls: true
# Slack configuration
slack:
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
# token: "xoxb-your-bot-token" # Alternative to webhook
# channel: "#notifications" # Default channel
username: "Notifier Bot"
icon_emoji: ":bell:"
# Channel-specific webhooks
# webhooks:
# "#alerts": "https://hooks.slack.com/services/ALERTS/WEBHOOK"
# "#monitoring": "https://hooks.slack.com/services/MONITORING/WEBHOOK"
# Ntfy configuration
ntfy:
server_url: "https://ntfy.sh"
# Token-based authentication (recommended for ntfy.sh)
# Supports both access tokens (tk_...) and publish tokens
# token: "tk_your_access_token"
# Basic authentication (alternative to token)
# username: "your-username"
# password: "your-password"
# Default topic if recipient not specified
# default_topic: "default-notifications"
# Skip TLS verification (for self-hosted servers with self-signed certs)
# insecure_skip_verify: false
logging:
level: "info" # Options: debug, info, warn, error
format: "json" # Options: json, text
output_path: "stdout" # Options: stdout, stderr, or file path
metrics:
enabled: true
port: 9090
path: "/metrics"
prometheus_enabled: true
health_check:
enabled: true
port: 8081
path: "/health"
interval: 30 # seconds
+95
View File
@@ -0,0 +1,95 @@
version: '3.8'
services:
notifier:
build:
context: .
dockerfile: Dockerfile
container_name: notifier
ports:
- "8080:8080" # REST API
- "50051:50051" # gRPC
- "9090:9090" # Metrics
- "8081:8081" # Health check
volumes:
- ./config.yaml:/app/config.yaml:ro
- notifier-data:/var/lib/notifier
environment:
- NOTIFIER_SERVER_MODE=both
- NOTIFIER_LOGGING_LEVEL=info
- NOTIFIER_LOGGING_FORMAT=json
restart: unless-stopped
networks:
- notifier-net
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Optional: Kafka for distributed queue
# kafka:
# image: confluentinc/cp-kafka:latest
# container_name: kafka
# ports:
# - "9092:9092"
# environment:
# KAFKA_BROKER_ID: 1
# KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
# KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
# KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
# depends_on:
# - zookeeper
# networks:
# - notifier-net
# zookeeper:
# image: confluentinc/cp-zookeeper:latest
# container_name: zookeeper
# ports:
# - "2181:2181"
# environment:
# ZOOKEEPER_CLIENT_PORT: 2181
# ZOOKEEPER_TICK_TIME: 2000
# networks:
# - notifier-net
# Optional: Prometheus for metrics collection
# prometheus:
# image: prom/prometheus:latest
# container_name: prometheus
# ports:
# - "9091:9090"
# volumes:
# - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
# - prometheus-data:/prometheus
# command:
# - '--config.file=/etc/prometheus/prometheus.yml'
# - '--storage.tsdb.path=/prometheus'
# networks:
# - notifier-net
# Optional: Grafana for visualization
# grafana:
# image: grafana/grafana:latest
# container_name: grafana
# ports:
# - "3000:3000"
# environment:
# - GF_SECURITY_ADMIN_PASSWORD=admin
# volumes:
# - grafana-data:/var/lib/grafana
# depends_on:
# - prometheus
# networks:
# - notifier-net
networks:
notifier-net:
driver: bridge
volumes:
notifier-data:
# prometheus-data:
# grafana-data:
+479
View File
@@ -0,0 +1,479 @@
# Ntfy Integration Guide
This guide explains how to use ntfy.sh notifications with the Notifier service.
## What is Ntfy?
[ntfy](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can send notifications to your phone, desktop, or any device that subscribes to your topics. It's perfect for:
- Push notifications to mobile devices
- Desktop notifications
- Server alerts and monitoring
- CI/CD pipeline notifications
- IoT device notifications
## Authentication Methods
### 1. Token-Based Authentication (Recommended)
Token authentication is the preferred method for ntfy.sh.
#### Access Tokens (for authenticated topics)
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.sh"
token: "tk_your_access_token"
```
Get an access token:
1. Go to https://ntfy.sh/account
2. Create an account or log in
3. Go to "Access Tokens"
4. Create a new token with appropriate permissions
5. Copy the token (starts with `tk_`)
#### Publish Tokens (for specific topics)
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.sh"
token: "your_publish_token"
```
Create a publish token:
1. Create a topic with reserved access
2. Generate a publish token for that topic
3. Use the token in your configuration
### 2. Basic Authentication
Alternative to token auth:
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.sh"
username: "your-username"
password: "your-password"
```
### 3. No Authentication (Public Topics)
For public topics on ntfy.sh:
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.sh"
# No token, username, or password needed
```
## Configuration Options
### Full Configuration Example
```yaml
notifiers:
ntfy:
# Server URL (default: https://ntfy.sh)
server_url: "https://ntfy.sh"
# Authentication (choose one method)
token: "tk_your_token" # Token auth (recommended)
# username: "user" # Or basic auth
# password: "pass"
# Optional: default topic if not specified in notification
default_topic: "my-default-topic"
# Optional: skip TLS verification (for self-hosted with self-signed certs)
insecure_skip_verify: false
```
### Self-Hosted Ntfy Server
```yaml
notifiers:
ntfy:
server_url: "https://ntfy.yourcompany.com"
token: "your_custom_token"
# For self-signed certificates
insecure_skip_verify: true
```
## Sending Notifications
### Basic Notification
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Hello from Notifier!",
"body": "This is a test notification",
"recipients": ["my-topic"]
}'
```
### With Priority
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"priority": 4,
"subject": "CRITICAL Alert",
"body": "Something important happened!",
"recipients": ["alerts"]
}'
```
Priority mapping:
- `0` (Low) → ntfy priority 2
- `1` (Normal) → ntfy priority 3 (default)
- `2` (High) → ntfy priority 4
- `3` (Critical) → ntfy priority 5 (max)
### With Tags (Emojis)
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Deployment Complete",
"body": "Application deployed successfully",
"recipients": ["deployments"],
"metadata": {
"tags": ["rocket", "tada", "white_check_mark"]
}
}'
```
Common tags:
- `warning`, `rotating_light`, `skull` - Alerts
- `tada`, `rocket`, `sparkles` - Success
- `x`, `no_entry`, `stop_sign` - Errors
- `information_source`, `eyes` - Info
### With Click Action
Make the notification clickable:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "New Pull Request",
"body": "PR #123 needs review",
"recipients": ["github-notifications"],
"metadata": {
"click": "https://github.com/your-org/your-repo/pull/123",
"tags": ["github"]
}
}'
```
### With Attachment
Attach an image or file:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Server Stats",
"body": "Current server metrics",
"recipients": ["monitoring"],
"metadata": {
"attach": "https://example.com/metrics.png",
"tags": ["chart_with_upwards_trend"]
}
}'
```
### With Custom Icon
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Custom Notification",
"body": "With a custom icon",
"recipients": ["custom-alerts"],
"metadata": {
"icon": "https://example.com/logo.png"
}
}'
```
### With Delayed Delivery
Schedule notification for later:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Reminder",
"body": "Meeting in 30 minutes",
"recipients": ["reminders"],
"metadata": {
"delay": "30m",
"tags": ["alarm_clock"]
}
}'
```
Delay formats:
- `30s` - 30 seconds
- `5m` - 5 minutes
- `2h` - 2 hours
- `tomorrow 10am` - Tomorrow at 10 AM
### With Action Buttons
Add interactive buttons:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Deploy to Production?",
"body": "Version 2.0 is ready",
"recipients": ["deployments"],
"metadata": {
"actions": [
{
"action": "view",
"label": "View Release",
"url": "https://github.com/your-org/your-repo/releases/v2.0",
"clear": true
},
{
"action": "http",
"label": "Deploy",
"url": "https://api.yourcompany.com/deploy",
"body": "{\"version\": \"2.0\"}",
"clear": true
}
]
}
}'
```
Action types:
- `view` - Open a URL
- `http` - Send HTTP request
- `broadcast` - Android broadcast intent
### With Email Forwarding
Forward to email (requires ntfy.sh tier 2+):
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "Important Alert",
"body": "This will also be sent via email",
"recipients": ["alerts"],
"metadata": {
"email": "admin@example.com",
"tags": ["email"]
}
}'
```
### Multiple Topics
Send to multiple topics:
```bash
curl -X POST http://localhost:8080/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{
"type": "ntfy",
"subject": "System Update",
"body": "System will restart in 5 minutes",
"recipients": ["admins", "monitoring", "alerts"],
"metadata": {
"tags": ["warning"]
}
}'
```
## Mobile App Setup
### iOS
1. Download ntfy from the App Store
2. Add a topic subscription
3. Use the same topic name in your notifications
### Android
1. Download ntfy from Google Play or F-Droid
2. Add a topic subscription
3. Configure notification settings
4. Use the same topic name in your notifications
### Desktop (Linux/macOS/Windows)
```bash
# Install ntfy CLI
curl -sSL https://ntfy.sh/install.sh | sh
# Subscribe to topics
ntfy subscribe mytopic
```
## Topic Naming Best Practices
### Public Topics
- Use unique, hard-to-guess names
- Consider including random strings: `myapp-alerts-x7k9p2`
- Anyone who knows the name can subscribe
### Private Topics (Recommended)
- Requires authentication
- Create reserved topics on ntfy.sh
- Use access control lists (ACLs)
### Topic Organization
```yaml
# Example topic structure
- myapp-prod-alerts # Production alerts
- myapp-prod-info # Production info
- myapp-staging-alerts # Staging alerts
- myapp-ci-cd # CI/CD notifications
- myapp-monitoring # Monitoring metrics
```
## Environment Variables
Override configuration with environment variables:
```bash
export NOTIFIER_NOTIFIERS_NTFY_SERVER_URL=https://ntfy.yourcompany.com
export NOTIFIER_NOTIFIERS_NTFY_TOKEN=tk_your_token
export NOTIFIER_NOTIFIERS_NTFY_DEFAULT_TOPIC=default-topic
export NOTIFIER_NOTIFIERS_NTFY_INSECURE_SKIP_VERIFY=false
```
## Security Considerations
### Token Security
- **Never commit tokens to version control**
- Store tokens in Kubernetes Secrets or environment variables
- Rotate tokens regularly
- Use publish tokens with limited scope when possible
### Topic Security
- Use reserved/private topics for sensitive data
- Don't include secrets in notification bodies
- Consider encryption for highly sensitive data
- Use unique topic names to prevent enumeration
### Self-Hosted Servers
- Use TLS with valid certificates
- Enable authentication
- Configure rate limiting
- Monitor access logs
- Keep ntfy server updated
## Kubernetes Deployment
### Using Secrets
```yaml
apiVersion: v1
kind: Secret
metadata:
name: notifier-secrets
type: Opaque
stringData:
ntfy-token: "tk_your_access_token"
```
### Deployment Configuration
```yaml
env:
- name: NOTIFIER_NOTIFIERS_NTFY_TOKEN
valueFrom:
secretKeyRef:
name: notifier-secrets
key: ntfy-token
```
## Rate Limits
### ntfy.sh Free Tier
- 250 messages/day per visitor
- Unlimited topics
- Message retention: 12 hours
### ntfy.sh Tier 1 ($5/month)
- 500 messages/day
- Message retention: 1 day
- Attachment & email support
### ntfy.sh Tier 2 ($10/month)
- 1000 messages/day
- Message retention: 7 days
- Higher attachment limits
### Self-Hosted
- Configure your own limits
- Full control over retention
- No external dependencies
## Troubleshooting
### Authentication Errors
```
Error: ntfy server returned status: 401
```
**Solution**: Verify token is correct and has necessary permissions
### Topic Not Found
```
Error: ntfy server returned status: 404
```
**Solution**: Check topic name spelling, ensure topic exists if using reserved topics
### TLS Errors (Self-Hosted)
```
Error: x509: certificate signed by unknown authority
```
**Solution**: Either fix certificate or set `insecure_skip_verify: true` (not recommended for production)
### Rate Limit Exceeded
```
Error: ntfy server returned status: 429
```
**Solution**: Reduce message frequency or upgrade ntfy.sh tier
### Connection Timeout
```
Error: context deadline exceeded
```
**Solution**: Check network connectivity, firewall rules, or server URL
## Examples
See [QUICKSTART.md](../QUICKSTART.md) for more examples of using ntfy with the Notifier service.
## Resources
- [Ntfy Documentation](https://docs.ntfy.sh)
- [Ntfy.sh Public Instance](https://ntfy.sh)
- [Self-Hosting Guide](https://docs.ntfy.sh/install/)
- [API Reference](https://docs.ntfy.sh/publish/)
+33
View File
@@ -1,3 +1,36 @@
module github.com/igodwin/notifier
go 1.23.2
require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/spf13/viper v1.19.0
google.golang.org/grpc v1.62.1
google.golang.org/protobuf v1.33.0
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+90
View File
@@ -0,0 +1,90 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+211
View File
@@ -0,0 +1,211 @@
package config
import (
"fmt"
"strings"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/notifier"
"github.com/spf13/viper"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Queue domain.QueueConfig `mapstructure:"queue"`
Notifiers NotifiersConfig `mapstructure:"notifiers"`
Logging LoggingConfig `mapstructure:"logging"`
Metrics MetricsConfig `mapstructure:"metrics"`
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
}
// ServerConfig contains server configuration
type ServerConfig struct {
GRPCPort int `mapstructure:"grpc_port"`
RESTPort int `mapstructure:"rest_port"`
Host string `mapstructure:"host"`
Mode string `mapstructure:"mode"` // "both", "grpc", "rest"
}
// NotifiersConfig contains configuration for all notifier types
type NotifiersConfig struct {
SMTP *notifier.SMTPConfig `mapstructure:"smtp"`
Slack *notifier.SlackConfig `mapstructure:"slack"`
Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"`
Stdout bool `mapstructure:"stdout"` // Enable stdout notifier
}
// LoggingConfig contains logging configuration
type LoggingConfig struct {
Level string `mapstructure:"level"` // debug, info, warn, error
Format string `mapstructure:"format"` // json, text
OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path
}
// MetricsConfig contains metrics/observability configuration
type MetricsConfig struct {
Enabled bool `mapstructure:"enabled"`
Port int `mapstructure:"port"`
Path string `mapstructure:"path"`
PrometheusEnabled bool `mapstructure:"prometheus_enabled"`
}
// HealthCheckConfig contains health check configuration
type HealthCheckConfig struct {
Enabled bool `mapstructure:"enabled"`
Port int `mapstructure:"port"`
Path string `mapstructure:"path"`
Interval int `mapstructure:"interval"` // seconds
}
// Load loads configuration from file and environment variables
func Load(configPath string) (*Config, error) {
v := viper.New()
// Set default values
setDefaults(v)
// Configure viper
v.SetConfigName("config")
v.SetConfigType("yaml")
if configPath != "" {
v.AddConfigPath(configPath)
}
// Also look in common locations
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("/etc/notifier")
v.AddConfigPath("$HOME/.notifier")
// Environment variable support
v.SetEnvPrefix("NOTIFIER")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// Read config file
if err := v.ReadInConfig(); err != nil {
// Config file is optional if environment variables are set
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
}
var config Config
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validate configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &config, nil
}
// setDefaults sets default configuration values
func setDefaults(v *viper.Viper) {
// Server defaults
v.SetDefault("server.grpc_port", 50051)
v.SetDefault("server.rest_port", 8080)
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.mode", "both")
// Queue defaults
v.SetDefault("queue.type", "local")
v.SetDefault("queue.max_size", 10000)
v.SetDefault("queue.worker_count", 10)
v.SetDefault("queue.retry_attempts", 3)
v.SetDefault("queue.retry_backoff", "exponential")
// Local queue defaults
v.SetDefault("queue.local.buffer_size", 1000)
v.SetDefault("queue.local.persist_to_disk", false)
// Logging defaults
v.SetDefault("logging.level", "info")
v.SetDefault("logging.format", "json")
v.SetDefault("logging.output_path", "stdout")
// Metrics defaults
v.SetDefault("metrics.enabled", true)
v.SetDefault("metrics.port", 9090)
v.SetDefault("metrics.path", "/metrics")
v.SetDefault("metrics.prometheus_enabled", true)
// Health check defaults
v.SetDefault("health_check.enabled", true)
v.SetDefault("health_check.port", 8081)
v.SetDefault("health_check.path", "/health")
v.SetDefault("health_check.interval", 30)
// Notifier defaults
v.SetDefault("notifiers.stdout", true)
v.SetDefault("notifiers.smtp.port", 587)
v.SetDefault("notifiers.smtp.use_tls", true)
v.SetDefault("notifiers.ntfy.server_url", "https://ntfy.sh")
}
// Validate validates the configuration
func (c *Config) Validate() error {
// Validate server config
if c.Server.GRPCPort < 1 || c.Server.GRPCPort > 65535 {
return fmt.Errorf("invalid gRPC port: %d", c.Server.GRPCPort)
}
if c.Server.RESTPort < 1 || c.Server.RESTPort > 65535 {
return fmt.Errorf("invalid REST port: %d", c.Server.RESTPort)
}
validModes := map[string]bool{"both": true, "grpc": true, "rest": true}
if !validModes[c.Server.Mode] {
return fmt.Errorf("invalid server mode: %s (must be both, grpc, or rest)", c.Server.Mode)
}
// Validate queue config
validQueueTypes := map[string]bool{"local": true, "kafka": true}
if !validQueueTypes[c.Queue.Type] {
return fmt.Errorf("invalid queue type: %s (must be local or kafka)", c.Queue.Type)
}
if c.Queue.Type == "kafka" && c.Queue.Kafka == nil {
return fmt.Errorf("Kafka queue type selected but no Kafka configuration provided")
}
// Validate at least one notifier is configured
if !c.HasAnyNotifier() {
return fmt.Errorf("at least one notifier must be configured")
}
return nil
}
// HasAnyNotifier checks if at least one notifier is configured
func (c *Config) HasAnyNotifier() bool {
return c.Notifiers.Stdout ||
c.Notifiers.SMTP != nil ||
c.Notifiers.Slack != nil ||
c.Notifiers.Ntfy != nil
}
// GetEnabledNotifiers returns a list of enabled notifier types
func (c *Config) GetEnabledNotifiers() []domain.NotificationType {
var enabled []domain.NotificationType
if c.Notifiers.Stdout {
enabled = append(enabled, domain.TypeStdout)
}
if c.Notifiers.SMTP != nil {
enabled = append(enabled, domain.TypeEmail)
}
if c.Notifiers.Slack != nil {
enabled = append(enabled, domain.TypeSlack)
}
if c.Notifiers.Ntfy != nil {
enabled = append(enabled, domain.TypeNtfy)
}
return enabled
}
+115
View File
@@ -0,0 +1,115 @@
package domain
import (
"time"
)
// Priority defines the urgency level of a notification
type Priority int
const (
PriorityLow Priority = iota
PriorityNormal
PriorityHigh
PriorityCritical
)
// NotificationType defines the channel through which to send the notification
type NotificationType string
const (
TypeEmail NotificationType = "email"
TypeSlack NotificationType = "slack"
TypeNtfy NotificationType = "ntfy"
TypeStdout NotificationType = "stdout"
)
// NotificationStatus represents the current state of a notification
type NotificationStatus string
const (
StatusPending NotificationStatus = "pending"
StatusQueued NotificationStatus = "queued"
StatusProcessing NotificationStatus = "processing"
StatusSent NotificationStatus = "sent"
StatusFailed NotificationStatus = "failed"
StatusRetrying NotificationStatus = "retrying"
)
// Notification represents a notification message with metadata
type Notification struct {
// ID is a unique identifier for the notification
ID string `json:"id"`
// Type specifies which notifier should handle this notification
Type NotificationType `json:"type"`
// Priority determines urgency and retry behavior
Priority Priority `json:"priority"`
// Status tracks the current state of the notification
Status NotificationStatus `json:"status"`
// Subject is the notification subject/title (used for email, slack, ntfy)
Subject string `json:"subject"`
// Body is the main content of the notification
Body string `json:"body"`
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
Recipients []string `json:"recipients"`
// Metadata contains additional provider-specific data
Metadata map[string]interface{} `json:"metadata,omitempty"`
// CreatedAt is when the notification was created
CreatedAt time.Time `json:"created_at"`
// ScheduledFor allows delayed sending (optional)
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
// SentAt is when the notification was successfully sent
SentAt *time.Time `json:"sent_at,omitempty"`
// RetryCount tracks how many times sending has been attempted
RetryCount int `json:"retry_count"`
// MaxRetries defines the maximum retry attempts
MaxRetries int `json:"max_retries"`
// LastError stores the most recent error message if failed
LastError string `json:"last_error,omitempty"`
}
// NotificationResult represents the outcome of sending a notification
type NotificationResult struct {
// NotificationID references the original notification
NotificationID string `json:"notification_id"`
// Success indicates if the notification was sent successfully
Success bool `json:"success"`
// Message provides additional context about the result
Message string `json:"message,omitempty"`
// Error contains error details if the notification failed
Error string `json:"error,omitempty"`
// SentAt is when the notification was sent
SentAt time.Time `json:"sent_at"`
// ProviderResponse contains raw response data from the notification provider
ProviderResponse map[string]interface{} `json:"provider_response,omitempty"`
}
// NotificationFilter is used for querying notifications
type NotificationFilter struct {
IDs []string `json:"ids,omitempty"`
Types []NotificationType `json:"types,omitempty"`
Statuses []NotificationStatus `json:"statuses,omitempty"`
Recipients []string `json:"recipients,omitempty"`
CreatedAfter *time.Time `json:"created_after,omitempty"`
CreatedBefore *time.Time `json:"created_before,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
+67
View File
@@ -0,0 +1,67 @@
package domain
import (
"context"
)
// Notifier is the core interface that all notification implementations must satisfy
type Notifier interface {
// Send sends a notification and returns the result
Send(ctx context.Context, notification *Notification) (*NotificationResult, error)
// Type returns the notification type this notifier handles
Type() NotificationType
// Validate checks if a notification can be sent with this notifier
Validate(notification *Notification) error
// Close performs cleanup when the notifier is no longer needed
Close() error
}
// NotifierFactory creates notifier instances based on configuration
type NotifierFactory interface {
// Create creates a notifier for the given type
Create(notificationType NotificationType) (Notifier, error)
// RegisterNotifier registers a custom notifier implementation
RegisterNotifier(notificationType NotificationType, notifier Notifier) error
// SupportedTypes returns all supported notification types
SupportedTypes() []NotificationType
}
// NotificationService is the high-level service interface for managing notifications
type NotificationService interface {
// Send queues a notification for delivery
Send(ctx context.Context, notification *Notification) (*NotificationResult, error)
// SendBatch queues multiple notifications for delivery
SendBatch(ctx context.Context, notifications []*Notification) ([]*NotificationResult, error)
// GetNotification retrieves a notification by ID
GetNotification(ctx context.Context, id string) (*Notification, error)
// ListNotifications retrieves notifications matching the filter
ListNotifications(ctx context.Context, filter *NotificationFilter) ([]*Notification, error)
// CancelNotification cancels a pending notification
CancelNotification(ctx context.Context, id string) error
// RetryNotification retries a failed notification
RetryNotification(ctx context.Context, id string) (*NotificationResult, error)
// GetStats returns notification statistics
GetStats(ctx context.Context) (*NotificationStats, error)
}
// NotificationStats contains statistics about notification processing
type NotificationStats struct {
TotalSent int64 `json:"total_sent"`
TotalFailed int64 `json:"total_failed"`
TotalPending int64 `json:"total_pending"`
TotalQueued int64 `json:"total_queued"`
ByType map[string]int64 `json:"by_type"`
ByStatus map[string]int64 `json:"by_status"`
AverageLatency float64 `json:"average_latency_ms"`
}
+111
View File
@@ -0,0 +1,111 @@
package domain
import (
"context"
)
// QueueMessage wraps a notification with queue-specific metadata
type QueueMessage struct {
// ID is a unique identifier for this queue message
ID string `json:"id"`
// Notification is the actual notification to be sent
Notification *Notification `json:"notification"`
// Attempt is the current delivery attempt number
Attempt int `json:"attempt"`
// EnqueuedAt is when the message was added to the queue
EnqueuedAt int64 `json:"enqueued_at"`
}
// Queue defines the interface for a notification queue
type Queue interface {
// Enqueue adds a notification to the queue
Enqueue(ctx context.Context, notification *Notification) error
// EnqueueBatch adds multiple notifications to the queue
EnqueueBatch(ctx context.Context, notifications []*Notification) error
// Dequeue retrieves the next notification from the queue
// Returns nil if the queue is empty
Dequeue(ctx context.Context) (*QueueMessage, error)
// Ack acknowledges successful processing of a message
Ack(ctx context.Context, messageID string) error
// Nack indicates processing failure and may requeue the message
Nack(ctx context.Context, messageID string, requeue bool) error
// Size returns the current number of messages in the queue
Size(ctx context.Context) (int64, error)
// Purge removes all messages from the queue
Purge(ctx context.Context) error
// Close cleanly shuts down the queue
Close() error
// HealthCheck verifies the queue is operational
HealthCheck(ctx context.Context) error
}
// QueueConfig contains configuration for queue implementations
type QueueConfig struct {
// Type specifies the queue implementation (local, kafka, etc.)
Type string `mapstructure:"type"`
// MaxSize is the maximum number of messages the queue can hold
MaxSize int64 `mapstructure:"max_size"`
// WorkerCount is the number of concurrent workers processing the queue
WorkerCount int `mapstructure:"worker_count"`
// RetryAttempts is the number of times to retry failed notifications
RetryAttempts int `mapstructure:"retry_attempts"`
// RetryBackoff is the backoff strategy for retries (exponential, linear, fixed)
RetryBackoff string `mapstructure:"retry_backoff"`
// Local queue specific config
Local *LocalQueueConfig `mapstructure:"local,omitempty"`
// Kafka specific config
Kafka *KafkaQueueConfig `mapstructure:"kafka,omitempty"`
}
// LocalQueueConfig contains configuration for the in-memory queue
type LocalQueueConfig struct {
// BufferSize is the channel buffer size
BufferSize int `mapstructure:"buffer_size"`
// PersistToDisk enables writing queue state to disk for recovery
PersistToDisk bool `mapstructure:"persist_to_disk"`
// PersistPath is where to store the queue state
PersistPath string `mapstructure:"persist_path"`
}
// KafkaQueueConfig contains configuration for Kafka queue
type KafkaQueueConfig struct {
// Brokers is the list of Kafka broker addresses
Brokers []string `mapstructure:"brokers"`
// Topic is the Kafka topic for notifications
Topic string `mapstructure:"topic"`
// ConsumerGroup is the Kafka consumer group ID
ConsumerGroup string `mapstructure:"consumer_group"`
// PartitionCount is the number of partitions for the topic
PartitionCount int `mapstructure:"partition_count"`
// ReplicationFactor is the replication factor for the topic
ReplicationFactor int `mapstructure:"replication_factor"`
// EnableIdempotence ensures exactly-once delivery semantics
EnableIdempotence bool `mapstructure:"enable_idempotence"`
// CompressionType defines compression (none, gzip, snappy, lz4, zstd)
CompressionType string `mapstructure:"compression_type"`
}
+107
View File
@@ -0,0 +1,107 @@
package notifier
import (
"context"
"fmt"
"sync"
"github.com/igodwin/notifier/internal/domain"
)
// Factory creates and manages notifier instances
type Factory struct {
notifiers map[domain.NotificationType]domain.Notifier
mu sync.RWMutex
}
// NewFactory creates a new notifier factory
func NewFactory() *Factory {
return &Factory{
notifiers: make(map[domain.NotificationType]domain.Notifier),
}
}
// Create creates a notifier for the given type
func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) {
f.mu.RLock()
defer f.mu.RUnlock()
notifier, exists := f.notifiers[notificationType]
if !exists {
return nil, fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier, nil
}
// RegisterNotifier registers a custom notifier implementation
func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.notifiers[notificationType]; exists {
return fmt.Errorf("notifier already registered for type: %s", notificationType)
}
f.notifiers[notificationType] = notifier
return nil
}
// SupportedTypes returns all supported notification types
func (f *Factory) SupportedTypes() []domain.NotificationType {
f.mu.RLock()
defer f.mu.RUnlock()
types := make([]domain.NotificationType, 0, len(f.notifiers))
for t := range f.notifiers {
types = append(types, t)
}
return types
}
// BaseNotifier provides common functionality for all notifiers
type BaseNotifier struct {
notificationType domain.NotificationType
}
// Type returns the notification type
func (b *BaseNotifier) Type() domain.NotificationType {
return b.notificationType
}
// Validate performs basic validation common to all notifiers
func (b *BaseNotifier) Validate(notification *domain.Notification) error {
if notification == nil {
return fmt.Errorf("notification is nil")
}
if len(notification.Recipients) == 0 {
return fmt.Errorf("notification has no recipients")
}
if notification.Type != b.notificationType {
return fmt.Errorf("notification type mismatch: expected %s, got %s", b.notificationType, notification.Type)
}
return nil
}
// Close performs cleanup (default implementation does nothing)
func (b *BaseNotifier) Close() error {
return nil
}
// ValidateContext checks if the context is valid
func ValidateContext(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("context is nil")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
+261
View File
@@ -0,0 +1,261 @@
package notifier
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// NtfyConfig contains ntfy.sh configuration
type NtfyConfig struct {
// ServerURL is the ntfy server URL (default: https://ntfy.sh)
ServerURL string `mapstructure:"server_url"`
// Token is the access token for authentication (preferred method)
// Supports both regular tokens (tk_...) and publish tokens
Token string `mapstructure:"token"`
// Username for basic authentication (alternative to token)
Username string `mapstructure:"username"`
// Password for basic authentication (alternative to token)
Password string `mapstructure:"password"`
// DefaultTopic is the default topic if not specified in notification
DefaultTopic string `mapstructure:"default_topic"`
// InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs)
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
}
// NtfyNotifier sends notifications via ntfy.sh
type NtfyNotifier struct {
BaseNotifier
config *NtfyConfig
httpClient *http.Client
}
// ntfyRequest represents the ntfy API request format
type ntfyRequest struct {
Topic string `json:"topic"`
Message string `json:"message"`
Title string `json:"title,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Attach string `json:"attach,omitempty"`
Actions []ntfyAction `json:"actions,omitempty"`
Icon string `json:"icon,omitempty"`
Delay string `json:"delay,omitempty"`
Email string `json:"email,omitempty"`
}
// ntfyAction represents an action button in ntfy
type ntfyAction struct {
Action string `json:"action"`
Label string `json:"label"`
URL string `json:"url,omitempty"`
Body string `json:"body,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// NewNtfyNotifier creates a new ntfy notifier
func NewNtfyNotifier(config *NtfyConfig) (*NtfyNotifier, error) {
if config == nil {
return nil, fmt.Errorf("ntfy config is required")
}
if config.ServerURL == "" {
config.ServerURL = "https://ntfy.sh" // Default public ntfy server
}
// Create HTTP client with optional TLS skip verify
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
if config.InsecureSkipVerify {
// For self-hosted servers with self-signed certificates
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
httpClient.Transport = transport
}
return &NtfyNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeNtfy,
},
config: config,
httpClient: httpClient,
}, nil
}
// Send sends a notification via ntfy
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := n.Validate(notification); err != nil {
return nil, err
}
// For ntfy, recipients are topics
recipients := notification.Recipients
if len(recipients) == 0 && n.config.DefaultTopic != "" {
recipients = []string{n.config.DefaultTopic}
}
for _, topic := range recipients {
req := ntfyRequest{
Topic: topic,
Message: notification.Body,
Title: notification.Subject,
Priority: n.mapPriority(notification.Priority),
}
// Add custom tags from metadata
if tags, ok := notification.Metadata["tags"].([]interface{}); ok {
for _, tag := range tags {
if tagStr, ok := tag.(string); ok {
req.Tags = append(req.Tags, tagStr)
}
}
}
// Add click action from metadata
if click, ok := notification.Metadata["click"].(string); ok {
req.Click = click
}
// Add attachment from metadata
if attach, ok := notification.Metadata["attach"].(string); ok {
req.Attach = attach
}
// Add icon from metadata
if icon, ok := notification.Metadata["icon"].(string); ok {
req.Icon = icon
}
// Add delay from metadata (e.g., "30s", "1m", "1h")
if delay, ok := notification.Metadata["delay"].(string); ok {
req.Delay = delay
}
// Add email from metadata (for email notifications)
if email, ok := notification.Metadata["email"].(string); ok {
req.Email = email
}
// Add actions from metadata
if actions, ok := notification.Metadata["actions"].([]interface{}); ok {
for _, action := range actions {
if actionMap, ok := action.(map[string]interface{}); ok {
ntfyAct := ntfyAction{}
if actionType, ok := actionMap["action"].(string); ok {
ntfyAct.Action = actionType
}
if label, ok := actionMap["label"].(string); ok {
ntfyAct.Label = label
}
if url, ok := actionMap["url"].(string); ok {
ntfyAct.URL = url
}
if body, ok := actionMap["body"].(string); ok {
ntfyAct.Body = body
}
if clear, ok := actionMap["clear"].(bool); ok {
ntfyAct.Clear = clear
}
req.Actions = append(req.Actions, ntfyAct)
}
}
}
if err := n.sendToTopic(ctx, &req); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Notification sent to %d topics", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"server": n.config.ServerURL,
"topics": notification.Recipients,
},
}, nil
}
// sendToTopic sends a notification to a specific ntfy topic
func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error {
url := fmt.Sprintf("%s", n.config.ServerURL)
jsonData, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal ntfy request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Add authentication if configured
if n.config.Token != "" {
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.config.Token))
} else if n.config.Username != "" && n.config.Password != "" {
httpReq.SetBasicAuth(n.config.Username, n.config.Password)
}
resp, err := n.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to send ntfy notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy server returned status: %d", resp.StatusCode)
}
return nil
}
// mapPriority maps domain priority to ntfy priority (1-5)
func (n *NtfyNotifier) mapPriority(priority domain.Priority) int {
switch priority {
case domain.PriorityLow:
return 2
case domain.PriorityNormal:
return 3
case domain.PriorityHigh:
return 4
case domain.PriorityCritical:
return 5
default:
return 3
}
}
// Close closes the HTTP client
func (n *NtfyNotifier) Close() error {
n.httpClient.CloseIdleConnections()
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// SlackConfig contains Slack webhook configuration
type SlackConfig struct {
WebhookURL string `mapstructure:"webhook_url"`
Token string `mapstructure:"token"`
Channel string `mapstructure:"channel"`
Username string `mapstructure:"username"`
IconEmoji string `mapstructure:"icon_emoji"`
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
}
// SlackNotifier sends notifications to Slack
type SlackNotifier struct {
BaseNotifier
config *SlackConfig
httpClient *http.Client
}
// slackMessage represents the Slack API request format
type slackMessage struct {
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
Text string `json:"text,omitempty"`
Blocks []slackBlock `json:"blocks,omitempty"`
Markdown bool `json:"mrkdwn,omitempty"`
}
// slackBlock represents a Slack block element
type slackBlock struct {
Type string `json:"type"`
Text *slackTextBlock `json:"text,omitempty"`
}
// slackTextBlock represents a text element in a Slack block
type slackTextBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
// NewSlackNotifier creates a new Slack notifier
func NewSlackNotifier(config *SlackConfig) (*SlackNotifier, error) {
if config == nil {
return nil, fmt.Errorf("Slack config is required")
}
// Either webhook URL or token is required
if config.WebhookURL == "" && config.Token == "" && len(config.Webhooks) == 0 {
return nil, fmt.Errorf("Slack webhook URL, token, or channel webhooks are required")
}
return &SlackNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeSlack,
},
config: config,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Send sends a notification to Slack
func (s *SlackNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
// For Slack, recipients are channel names or webhook URLs
for _, recipient := range notification.Recipients {
msg := s.buildMessage(notification, recipient)
webhookURL := s.getWebhookURL(recipient)
if err := s.sendToSlack(ctx, webhookURL, msg); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Slack notification sent to %d channels", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"channels": notification.Recipients,
},
}, nil
}
// buildMessage constructs a Slack message with rich formatting
func (s *SlackNotifier) buildMessage(notification *domain.Notification, channel string) *slackMessage {
msg := &slackMessage{
Channel: channel,
Username: s.config.Username,
IconEmoji: s.config.IconEmoji,
Markdown: true,
}
// Use blocks for rich formatting if both subject and body exist
if notification.Subject != "" && notification.Body != "" {
msg.Blocks = []slackBlock{
{
Type: "header",
Text: &slackTextBlock{
Type: "plain_text",
Text: notification.Subject,
},
},
{
Type: "section",
Text: &slackTextBlock{
Type: "mrkdwn",
Text: notification.Body,
},
},
}
} else {
// Fallback to simple text
if notification.Subject != "" {
msg.Text = fmt.Sprintf("*%s*\n%s", notification.Subject, notification.Body)
} else {
msg.Text = notification.Body
}
}
// Add priority indicator for high priority notifications
if notification.Priority >= domain.PriorityHigh {
priorityEmoji := ":warning:"
if notification.Priority == domain.PriorityCritical {
priorityEmoji = ":rotating_light:"
}
msg.Blocks = append([]slackBlock{
{
Type: "context",
Text: &slackTextBlock{
Type: "mrkdwn",
Text: fmt.Sprintf("%s *Priority: %d*", priorityEmoji, notification.Priority),
},
},
}, msg.Blocks...)
}
return msg
}
// getWebhookURL returns the webhook URL for a specific channel
func (s *SlackNotifier) getWebhookURL(channel string) string {
// Check for channel-specific webhook
if webhook, ok := s.config.Webhooks[channel]; ok {
return webhook
}
// Fall back to default webhook URL
return s.config.WebhookURL
}
// sendToSlack sends the message to Slack via webhook
func (s *SlackNotifier) sendToSlack(ctx context.Context, webhookURL string, msg *slackMessage) error {
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal Slack message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Add token authentication if configured
if s.config.Token != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.Token))
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send Slack notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Slack API returned status: %d", resp.StatusCode)
}
return nil
}
// Close closes the HTTP client
func (s *SlackNotifier) Close() error {
s.httpClient.CloseIdleConnections()
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package notifier
import (
"context"
"fmt"
"net/smtp"
"strings"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// SMTPConfig contains SMTP server configuration
type SMTPConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
UseTLS bool `mapstructure:"use_tls"`
}
// SMTPNotifier sends notifications via email using SMTP
type SMTPNotifier struct {
BaseNotifier
config *SMTPConfig
}
// NewSMTPNotifier creates a new SMTP notifier
func NewSMTPNotifier(config *SMTPConfig) (*SMTPNotifier, error) {
if config == nil {
return nil, fmt.Errorf("SMTP config is required")
}
if config.Host == "" {
return nil, fmt.Errorf("SMTP host is required")
}
if config.Port == 0 {
config.Port = 587 // Default SMTP submission port
}
if config.From == "" {
return nil, fmt.Errorf("SMTP from address is required")
}
return &SMTPNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeEmail,
},
config: config,
}, nil
}
// Send sends a notification via email
func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
// Validate email recipients
for _, recipient := range notification.Recipients {
if !strings.Contains(recipient, "@") {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: fmt.Sprintf("invalid email address: %s", recipient),
SentAt: time.Now(),
}, fmt.Errorf("invalid email address: %s", recipient)
}
}
// Build email message
message := s.buildMessage(notification)
// Send email
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
err := smtp.SendMail(addr, auth, s.config.From, notification.Recipients, []byte(message))
if err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, fmt.Errorf("failed to send email: %w", err)
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Email sent to %d recipients", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"smtp_server": addr,
"from": s.config.From,
"to": notification.Recipients,
},
}, nil
}
// buildMessage constructs the email message with headers
func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("From: %s\r\n", s.config.From))
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n")
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("\r\n")
builder.WriteString(notification.Body)
return builder.String()
}
// Validate checks if the notification is valid for SMTP
func (s *SMTPNotifier) Validate(notification *domain.Notification) error {
if err := s.BaseNotifier.Validate(notification); err != nil {
return err
}
if notification.Subject == "" {
return fmt.Errorf("email subject is required")
}
if notification.Body == "" {
return fmt.Errorf("email body is required")
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package notifier
import (
"context"
"fmt"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// StdoutNotifier sends notifications to stdout (useful for debugging)
type StdoutNotifier struct {
BaseNotifier
}
// NewStdoutNotifier creates a new stdout notifier
func NewStdoutNotifier() *StdoutNotifier {
return &StdoutNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeStdout,
},
}
}
// Send sends a notification to stdout
func (s *StdoutNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
fmt.Println("========================================")
fmt.Printf("Notification ID: %s\n", notification.ID)
fmt.Printf("Type: %s\n", notification.Type)
fmt.Printf("Priority: %d\n", notification.Priority)
fmt.Printf("Recipients: %v\n", notification.Recipients)
fmt.Printf("Subject: %s\n", notification.Subject)
fmt.Printf("Body:\n%s\n", notification.Body)
fmt.Println("========================================")
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "Notification printed to stdout",
SentAt: time.Now(),
}, nil
}
+297
View File
@@ -0,0 +1,297 @@
package queue
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/igodwin/notifier/internal/domain"
"github.com/google/uuid"
)
// LocalQueue is an in-memory queue implementation
type LocalQueue struct {
queue chan *domain.QueueMessage
messages map[string]*domain.QueueMessage
mu sync.RWMutex
config *domain.LocalQueueConfig
persistToDisk bool
persistPath string
closed bool
closeChan chan struct{}
}
// NewLocalQueue creates a new local queue instance
func NewLocalQueue(config *domain.LocalQueueConfig) (*LocalQueue, error) {
if config == nil {
config = &domain.LocalQueueConfig{
BufferSize: 1000,
PersistToDisk: false,
}
}
lq := &LocalQueue{
queue: make(chan *domain.QueueMessage, config.BufferSize),
messages: make(map[string]*domain.QueueMessage),
config: config,
persistToDisk: config.PersistToDisk,
persistPath: config.PersistPath,
closeChan: make(chan struct{}),
}
// Load persisted messages if enabled
if lq.persistToDisk && lq.persistPath != "" {
if err := lq.loadFromDisk(); err != nil {
return nil, fmt.Errorf("failed to load persisted queue: %w", err)
}
}
return lq, nil
}
// Enqueue adds a notification to the queue
func (lq *LocalQueue) Enqueue(ctx context.Context, notification *domain.Notification) error {
lq.mu.Lock()
defer lq.mu.Unlock()
if lq.closed {
return fmt.Errorf("queue is closed")
}
msg := &domain.QueueMessage{
ID: uuid.New().String(),
Notification: notification,
Attempt: 0,
EnqueuedAt: time.Now().Unix(),
}
select {
case lq.queue <- msg:
lq.messages[msg.ID] = msg
notification.Status = domain.StatusQueued
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-lq.closeChan:
return fmt.Errorf("queue is closed")
}
}
// EnqueueBatch adds multiple notifications to the queue
func (lq *LocalQueue) EnqueueBatch(ctx context.Context, notifications []*domain.Notification) error {
lq.mu.Lock()
defer lq.mu.Unlock()
if lq.closed {
return fmt.Errorf("queue is closed")
}
for _, notification := range notifications {
msg := &domain.QueueMessage{
ID: uuid.New().String(),
Notification: notification,
Attempt: 0,
EnqueuedAt: time.Now().Unix(),
}
select {
case lq.queue <- msg:
lq.messages[msg.ID] = msg
notification.Status = domain.StatusQueued
case <-ctx.Done():
return ctx.Err()
case <-lq.closeChan:
return fmt.Errorf("queue is closed")
}
}
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
}
// Dequeue retrieves the next notification from the queue
func (lq *LocalQueue) Dequeue(ctx context.Context) (*domain.QueueMessage, error) {
if lq.closed {
return nil, fmt.Errorf("queue is closed")
}
select {
case msg := <-lq.queue:
lq.mu.Lock()
msg.Attempt++
msg.Notification.Status = domain.StatusProcessing
lq.mu.Unlock()
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-lq.closeChan:
return nil, fmt.Errorf("queue is closed")
}
}
// Ack acknowledges successful processing of a message
func (lq *LocalQueue) Ack(ctx context.Context, messageID string) error {
lq.mu.Lock()
defer lq.mu.Unlock()
if msg, exists := lq.messages[messageID]; exists {
msg.Notification.Status = domain.StatusSent
delete(lq.messages, messageID)
if lq.persistToDisk {
return lq.persistToDiskSync()
}
}
return nil
}
// Nack indicates processing failure and may requeue the message
func (lq *LocalQueue) Nack(ctx context.Context, messageID string, requeue bool) error {
lq.mu.Lock()
defer lq.mu.Unlock()
msg, exists := lq.messages[messageID]
if !exists {
return fmt.Errorf("message not found: %s", messageID)
}
if requeue {
msg.Notification.Status = domain.StatusRetrying
select {
case lq.queue <- msg:
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-lq.closeChan:
return fmt.Errorf("queue is closed")
}
} else {
msg.Notification.Status = domain.StatusFailed
delete(lq.messages, messageID)
if lq.persistToDisk {
return lq.persistToDiskSync()
}
}
return nil
}
// Size returns the current number of messages in the queue
func (lq *LocalQueue) Size(ctx context.Context) (int64, error) {
lq.mu.RLock()
defer lq.mu.RUnlock()
return int64(len(lq.queue)), nil
}
// Purge removes all messages from the queue
func (lq *LocalQueue) Purge(ctx context.Context) error {
lq.mu.Lock()
defer lq.mu.Unlock()
// Drain the channel
for len(lq.queue) > 0 {
<-lq.queue
}
lq.messages = make(map[string]*domain.QueueMessage)
if lq.persistToDisk {
return lq.persistToDiskSync()
}
return nil
}
// Close cleanly shuts down the queue
func (lq *LocalQueue) Close() error {
lq.mu.Lock()
defer lq.mu.Unlock()
if lq.closed {
return nil
}
lq.closed = true
close(lq.closeChan)
if lq.persistToDisk {
if err := lq.persistToDiskSync(); err != nil {
return err
}
}
close(lq.queue)
return nil
}
// HealthCheck verifies the queue is operational
func (lq *LocalQueue) HealthCheck(ctx context.Context) error {
lq.mu.RLock()
defer lq.mu.RUnlock()
if lq.closed {
return fmt.Errorf("queue is closed")
}
return nil
}
// persistToDiskSync persists the queue state to disk (must be called with lock held)
func (lq *LocalQueue) persistToDiskSync() error {
if !lq.persistToDisk || lq.persistPath == "" {
return nil
}
data, err := json.Marshal(lq.messages)
if err != nil {
return fmt.Errorf("failed to marshal queue state: %w", err)
}
if err := os.WriteFile(lq.persistPath, data, 0644); err != nil {
return fmt.Errorf("failed to write queue state: %w", err)
}
return nil
}
// loadFromDisk loads the queue state from disk
func (lq *LocalQueue) loadFromDisk() error {
if lq.persistPath == "" {
return nil
}
data, err := os.ReadFile(lq.persistPath)
if err != nil {
if os.IsNotExist(err) {
return nil // No persisted state yet
}
return fmt.Errorf("failed to read queue state: %w", err)
}
var messages map[string]*domain.QueueMessage
if err := json.Unmarshal(data, &messages); err != nil {
return fmt.Errorf("failed to unmarshal queue state: %w", err)
}
// Re-enqueue persisted messages
for _, msg := range messages {
lq.queue <- msg
lq.messages[msg.ID] = msg
}
return nil
}
+383
View File
@@ -0,0 +1,383 @@
package service
import (
"context"
"fmt"
"sync"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// NotificationService implements the domain.NotificationService interface
type NotificationService struct {
factory domain.NotifierFactory
queue domain.Queue
notifications map[string]*domain.Notification
mu sync.RWMutex
workerCount int
stopChan chan struct{}
wg sync.WaitGroup
}
// NewNotificationService creates a new notification service
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int) *NotificationService {
if workerCount <= 0 {
workerCount = 10
}
return &NotificationService{
factory: factory,
queue: queue,
notifications: make(map[string]*domain.Notification),
workerCount: workerCount,
stopChan: make(chan struct{}),
}
}
// Start starts the worker pool
func (s *NotificationService) Start(ctx context.Context) error {
for i := 0; i < s.workerCount; i++ {
s.wg.Add(1)
go s.worker(ctx, i)
}
return nil
}
// Stop stops the service gracefully
func (s *NotificationService) Stop() error {
close(s.stopChan)
s.wg.Wait()
return s.queue.Close()
}
// worker processes notifications from the queue
func (s *NotificationService) worker(ctx context.Context, id int) {
defer s.wg.Done()
for {
select {
case <-s.stopChan:
return
case <-ctx.Done():
return
default:
// Try to dequeue with timeout
workerCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
msg, err := s.queue.Dequeue(workerCtx)
cancel()
if err != nil {
if err == context.DeadlineExceeded {
continue
}
time.Sleep(100 * time.Millisecond)
continue
}
if msg == nil {
time.Sleep(100 * time.Millisecond)
continue
}
// Process the notification
s.processNotification(ctx, msg)
}
}
}
// processNotification sends a notification and handles the result
func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) {
notification := msg.Notification
// Get the appropriate notifier
notifier, err := s.factory.Create(notification.Type)
if err != nil {
notification.Status = domain.StatusFailed
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)
s.queue.Nack(ctx, msg.ID, false)
s.updateNotification(notification)
return
}
// Send the notification
result, err := notifier.Send(ctx, notification)
if err != nil || !result.Success {
notification.RetryCount++
notification.LastError = result.Error
if err != nil {
notification.LastError = err.Error()
}
// Check if we should retry
if notification.RetryCount < notification.MaxRetries {
notification.Status = domain.StatusRetrying
s.queue.Nack(ctx, msg.ID, true) // Requeue
} else {
notification.Status = domain.StatusFailed
s.queue.Nack(ctx, msg.ID, false) // Don't requeue
}
} else {
notification.Status = domain.StatusSent
now := time.Now()
notification.SentAt = &now
s.queue.Ack(ctx, msg.ID)
}
s.updateNotification(notification)
}
// Send queues a notification for delivery
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
// Store the notification
s.storeNotification(notification)
// Enqueue for processing
if err := s.queue.Enqueue(ctx, notification); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: fmt.Sprintf("failed to enqueue: %v", err),
SentAt: time.Now(),
}, err
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "notification queued successfully",
SentAt: time.Now(),
}, nil
}
// SendBatch queues multiple notifications for delivery
func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
results := make([]*domain.NotificationResult, 0, len(notifications))
// Store all notifications
for _, notification := range notifications {
s.storeNotification(notification)
}
// Enqueue batch
if err := s.queue.EnqueueBatch(ctx, notifications); err != nil {
return nil, fmt.Errorf("failed to enqueue batch: %w", err)
}
// Create results
for _, notification := range notifications {
results = append(results, &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "notification queued successfully",
SentAt: time.Now(),
})
}
return results, nil
}
// GetNotification retrieves a notification by ID
func (s *NotificationService) GetNotification(ctx context.Context, id string) (*domain.Notification, error) {
s.mu.RLock()
defer s.mu.RUnlock()
notification, exists := s.notifications[id]
if !exists {
return nil, fmt.Errorf("notification not found: %s", id)
}
return notification, nil
}
// ListNotifications retrieves notifications matching the filter
func (s *NotificationService) ListNotifications(ctx context.Context, filter *domain.NotificationFilter) ([]*domain.Notification, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Simple in-memory filtering
var results []*domain.Notification
for _, notification := range s.notifications {
if s.matchesFilter(notification, filter) {
results = append(results, notification)
}
}
// Apply limit and offset
if filter.Offset > 0 && filter.Offset < len(results) {
results = results[filter.Offset:]
}
if filter.Limit > 0 && filter.Limit < len(results) {
results = results[:filter.Limit]
}
return results, nil
}
// CancelNotification cancels a pending notification
func (s *NotificationService) CancelNotification(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
notification, exists := s.notifications[id]
if !exists {
return fmt.Errorf("notification not found: %s", id)
}
if notification.Status == domain.StatusSent {
return fmt.Errorf("notification already sent")
}
notification.Status = domain.StatusFailed
notification.LastError = "cancelled by user"
return nil
}
// RetryNotification retries a failed notification
func (s *NotificationService) RetryNotification(ctx context.Context, id string) (*domain.NotificationResult, error) {
notification, err := s.GetNotification(ctx, id)
if err != nil {
return nil, err
}
if notification.Status == domain.StatusSent {
return &domain.NotificationResult{
NotificationID: id,
Success: false,
Error: "notification already sent",
SentAt: time.Now(),
}, fmt.Errorf("notification already sent")
}
// Reset retry count and status
notification.RetryCount = 0
notification.Status = domain.StatusPending
// Re-enqueue
return s.Send(ctx, notification)
}
// GetStats returns notification statistics
func (s *NotificationService) GetStats(ctx context.Context) (*domain.NotificationStats, error) {
s.mu.RLock()
defer s.mu.RUnlock()
stats := &domain.NotificationStats{
ByType: make(map[string]int64),
ByStatus: make(map[string]int64),
}
for _, notification := range s.notifications {
switch notification.Status {
case domain.StatusSent:
stats.TotalSent++
case domain.StatusFailed:
stats.TotalFailed++
case domain.StatusPending:
stats.TotalPending++
case domain.StatusQueued:
stats.TotalQueued++
}
stats.ByType[string(notification.Type)]++
stats.ByStatus[string(notification.Status)]++
}
return stats, nil
}
// storeNotification stores a notification in memory
func (s *NotificationService) storeNotification(notification *domain.Notification) {
s.mu.Lock()
defer s.mu.Unlock()
s.notifications[notification.ID] = notification
}
// updateNotification updates a notification in memory
func (s *NotificationService) updateNotification(notification *domain.Notification) {
s.mu.Lock()
defer s.mu.Unlock()
s.notifications[notification.ID] = notification
}
// matchesFilter checks if a notification matches the filter
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
if filter == nil {
return true
}
// Check IDs
if len(filter.IDs) > 0 {
found := false
for _, id := range filter.IDs {
if notification.ID == id {
found = true
break
}
}
if !found {
return false
}
}
// Check types
if len(filter.Types) > 0 {
found := false
for _, t := range filter.Types {
if notification.Type == t {
found = true
break
}
}
if !found {
return false
}
}
// Check statuses
if len(filter.Statuses) > 0 {
found := false
for _, s := range filter.Statuses {
if notification.Status == s {
found = true
break
}
}
if !found {
return false
}
}
// Check recipients
if len(filter.Recipients) > 0 {
found := false
for _, fr := range filter.Recipients {
for _, nr := range notification.Recipients {
if fr == nr {
found = true
break
}
}
if found {
break
}
}
if !found {
return false
}
}
// Check time ranges
if filter.CreatedAfter != nil && notification.CreatedAt.Before(*filter.CreatedAfter) {
return false
}
if filter.CreatedBefore != nil && notification.CreatedAt.After(*filter.CreatedBefore) {
return false
}
return true
}
+43
View File
@@ -0,0 +1,43 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: notifier-config
labels:
app: notifier
data:
config.yaml: |
server:
grpc_port: 50051
rest_port: 8080
host: "0.0.0.0"
mode: "both"
queue:
type: "local"
max_size: 10000
worker_count: 10
retry_attempts: 3
retry_backoff: "exponential"
local:
buffer_size: 1000
persist_to_disk: false
notifiers:
stdout: true
logging:
level: "info"
format: "json"
output_path: "stdout"
metrics:
enabled: true
port: 9090
path: "/metrics"
prometheus_enabled: true
health_check:
enabled: true
port: 8081
path: "/health"
interval: 30
+97
View File
@@ -0,0 +1,97 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: notifier
labels:
app: notifier
version: v1
spec:
replicas: 3
selector:
matchLabels:
app: notifier
template:
metadata:
labels:
app: notifier
version: v1
spec:
serviceAccountName: notifier
containers:
- name: notifier
image: notifier:latest
imagePullPolicy: Always
ports:
- name: rest
containerPort: 8080
protocol: TCP
- name: grpc
containerPort: 50051
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
- name: health
containerPort: 8081
protocol: TCP
env:
- name: NOTIFIER_SERVER_MODE
value: "both"
- name: NOTIFIER_LOGGING_LEVEL
value: "info"
- name: NOTIFIER_LOGGING_FORMAT
value: "json"
- name: NOTIFIER_QUEUE_TYPE
value: "local"
volumeMounts:
- name: config
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
- name: queue-storage
mountPath: /var/lib/notifier
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
volumes:
- name: config
configMap:
name: notifier-config
- name: queue-storage
emptyDir: {}
restartPolicy: Always
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: notifier
labels:
app: notifier
+43
View File
@@ -0,0 +1,43 @@
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: notifier-hpa
labels:
app: notifier
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: notifier
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 2
periodSeconds: 60
selectPolicy: Max
+26
View File
@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notifier-ingress
labels:
app: notifier
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- notifier.example.com
secretName: notifier-tls
rules:
- host: notifier.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: notifier-rest
port:
number: 8080
+20
View File
@@ -0,0 +1,20 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: notifier
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
- ingress.yaml
- hpa.yaml
commonLabels:
app: notifier
managed-by: kustomize
images:
- name: notifier
newName: your-registry/notifier
newTag: latest
+45
View File
@@ -0,0 +1,45 @@
# Example secret for notifier credentials
# DO NOT commit actual secrets to version control
# Use sealed-secrets, external-secrets, or vault in production
apiVersion: v1
kind: Secret
metadata:
name: notifier-secrets
labels:
app: notifier
type: Opaque
stringData:
# SMTP credentials
smtp-username: "your-email@gmail.com"
smtp-password: "your-app-password"
# Slack webhook URL
slack-webhook-url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
# Ntfy token
ntfy-token: "tk_your_token"
# Kafka credentials (if using Kafka)
kafka-username: "kafka-user"
kafka-password: "kafka-password"
---
# Example of using secrets in deployment
# Add this to deployment.yaml under spec.template.spec.containers[0].env
# - name: NOTIFIER_NOTIFIERS_SMTP_USERNAME
# valueFrom:
# secretKeyRef:
# name: notifier-secrets
# key: smtp-username
# - name: NOTIFIER_NOTIFIERS_SMTP_PASSWORD
# valueFrom:
# secretKeyRef:
# name: notifier-secrets
# key: smtp-password
# - name: NOTIFIER_NOTIFIERS_SLACK_WEBHOOK_URL
# valueFrom:
# secretKeyRef:
# name: notifier-secrets
# key: slack-webhook-url
+50
View File
@@ -0,0 +1,50 @@
apiVersion: v1
kind: Service
metadata:
name: notifier-rest
labels:
app: notifier
service: rest
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: rest
protocol: TCP
name: http
selector:
app: notifier
---
apiVersion: v1
kind: Service
metadata:
name: notifier-grpc
labels:
app: notifier
service: grpc
spec:
type: ClusterIP
ports:
- port: 50051
targetPort: grpc
protocol: TCP
name: grpc
selector:
app: notifier
---
apiVersion: v1
kind: Service
metadata:
name: notifier-metrics
labels:
app: notifier
service: metrics
spec:
type: ClusterIP
ports:
- port: 9090
targetPort: metrics
protocol: TCP
name: metrics
selector:
app: notifier