Basic impl added
This commit is contained in:
@@ -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
|
||||||
Generated
+8
@@ -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
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+9
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+320
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
@@ -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.
|
[](https://golang.org)
|
||||||
|
[](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
|
## Features
|
||||||
|
|
||||||
- **Multi-Mode Notification**: Supports different notification methods including SMTP email, SMS, Slack, Ntfy, and standard output.
|
### Core Capabilities
|
||||||
- **Extensible Design**: Add new notification modes by implementing the `Notifier` interface.
|
- 🔔 **Multi-Channel Support**: Email (SMTP), Slack, Ntfy.sh, and Stdout
|
||||||
- **gRPC and REST APIs**: Accessible through both gRPC and RESTful APIs, allowing easy integration into various systems.
|
- 🚀 **Dual API**: REST and gRPC running simultaneously in one process
|
||||||
- **Configuration Management**: Easily configurable for different environments and API keys.
|
- 📦 **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
|
## Project Structure
|
||||||
|
|
||||||
```plaintext
|
```
|
||||||
notifier/
|
notifier/
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── grpc/ # gRPC service definitions and generated code
|
│ ├── grpc/
|
||||||
│ └── rest/ # REST API handlers and router
|
│ │ └── notifier.proto # gRPC service definition
|
||||||
|
│ └── rest/
|
||||||
|
│ ├── handlers.go # HTTP handlers
|
||||||
|
│ ├── router.go # Route configuration
|
||||||
|
│ └── types.go # Request/response types
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── grpcserver/ # Entrypoint for running the gRPC server
|
│ ├── server/main.go # Combined server (recommended)
|
||||||
│ └── restserver/ # Entrypoint for running the REST server
|
│ ├── grpcserver/main.go # gRPC only
|
||||||
|
│ └── restserver/main.go # REST only
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # Configuration loading
|
│ ├── config/
|
||||||
│ └── notifier/ # Core notification logic with multiple implementations
|
│ │ └── config.go # Configuration management
|
||||||
├── pkg/
|
│ ├── domain/
|
||||||
│ ├── grpcclient/ # gRPC client for interacting with the notifier service
|
│ │ ├── notification.go # Core types
|
||||||
│ └── restclient/ # REST client for interacting with the notifier service
|
│ │ ├── notifier.go # Notifier interface
|
||||||
├── Dockerfile # Docker configuration for deploying as a microservice
|
│ │ └── queue.go # Queue interface
|
||||||
├── LICENSE # License file
|
│ ├── notifier/
|
||||||
├── README.md # Documentation
|
│ │ ├── notifier.go # Factory & base
|
||||||
└── go.mod # Defines the module's dependencies, module path, and Go version
|
│ │ ├── 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
|
### Build Commands
|
||||||
|
|
||||||
- **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:
|
|
||||||
|
|
||||||
```bash
|
```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
|
1. Create `internal/notifier/mynotifier.go`
|
||||||
|
2. Implement `domain.Notifier` interface
|
||||||
To start the gRPC server:
|
3. Add config struct to `internal/config/config.go`
|
||||||
|
4. Register in `cmd/server/main.go`
|
||||||
```bash
|
5. Update `config.yaml` with example config
|
||||||
cd cmd/grpcserver
|
6. Add tests
|
||||||
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.
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
client, err := grpcclient.NewClient("localhost:50051")
|
type MyNotifier struct {
|
||||||
if err != nil {
|
BaseNotifier
|
||||||
log.Fatalf("Failed to create gRPC client: %v", err)
|
config *MyNotifierConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.SendNotification("recipient@example.com", "Hello via gRPC!")
|
func (m *MyNotifier) Send(ctx context.Context, n *domain.Notification) (*domain.NotificationResult, error) {
|
||||||
if err != nil {
|
// Implementation
|
||||||
log.Fatalf("Failed to send notification: %v", err)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Using REST API
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed guide.
|
||||||
|
|
||||||
1. Define a REST client using the `pkg/restclient` package.
|
## Documentation
|
||||||
2. Call `SendNotification` via REST.
|
|
||||||
|
|
||||||
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
|
## Testing
|
||||||
client := restclient.NewClient("http://localhost:8080")
|
|
||||||
err := client.SendNotification("recipient@example.com", "Hello via REST!")
|
```bash
|
||||||
if err != nil {
|
# Run all tests
|
||||||
log.Fatalf("Failed to send notification: %v", err)
|
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
|
Returns:
|
||||||
type Notifier interface {
|
```json
|
||||||
Send(notification Notification) error
|
{
|
||||||
|
"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
|
## 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.
|
1. Fork the repository
|
||||||
2. Create a feature branch (`git checkout -b feature/NewMode`).
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
3. Commit your changes (`git commit -am 'Add new notification mode'`).
|
3. Write tests for your changes
|
||||||
4. Push to the branch (`git push origin feature/NewMode`).
|
4. Ensure tests pass (`go test ./...`)
|
||||||
5. Create a new Pull Request.
|
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
|
## 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**
|
||||||
|
|||||||
@@ -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
@@ -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 ¬ifierServiceClient{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",
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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/)
|
||||||
@@ -1,3 +1,36 @@
|
|||||||
module github.com/igodwin/notifier
|
module github.com/igodwin/notifier
|
||||||
|
|
||||||
go 1.23.2
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user