Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d34a4bf
feat: upgrade thunder version to 0.32.0 and update related configurat…
Aravinda-HWK Apr 23, 2026
6ea5fb5
feat: add console runtime configuration and update database path in d…
Aravinda-HWK Apr 23, 2026
84cfe5d
feat: update docker-compose and gen-thunder script for postfix volume…
Aravinda-HWK Apr 23, 2026
c8e3789
feat: enhance certbot and OpenDKIM scripts for improved domain handli…
Aravinda-HWK Apr 23, 2026
a83075f
feat: remove deprecated Thunder user scripts and update configuration…
Aravinda-HWK Apr 24, 2026
a1eebbf
Add script to create a single-page application resource in Thunder
Aravinda-HWK Apr 24, 2026
4150a75
feat: upgrade authentication to use XOAUTH2 and remove password requi…
Aravinda-HWK Apr 24, 2026
bdcbee2
feat: update NewMailer function to use sender as username for XOAUTH2…
Aravinda-HWK Apr 24, 2026
0a06c66
refactor: simplify XOAUTH2 user identifier handling in NewMailer
Aravinda-HWK Apr 24, 2026
21cda1e
feat: add OAuth client email authorization configuration and update R…
Aravinda-HWK Apr 24, 2026
a1148b7
Merge branch 'main' into 26-feature-upgrade-the-thunder-version-to-th…
Aravinda-HWK Apr 24, 2026
6d4be28
refactor: remove unused sample resource creation logic from script
Aravinda-HWK Apr 24, 2026
72266ee
feat: upgrade server to enforce HTTPS and update related configurations
Aravinda-HWK Apr 25, 2026
ef0998b
feat: update email recipient handling to support multiple recipients
Aravinda-HWK Apr 25, 2026
9fb21ea
feat(thunder): add bootstrap ConfigMap and sample resources
Aravinda-HWK May 24, 2026
e252f1b
feat(thunder): add annotations for ConfigMap pre-install and pre-upgr…
Aravinda-HWK May 24, 2026
f9c5de3
feat(thunder): add consent server configuration with SQLite support
Aravinda-HWK May 24, 2026
4228e44
fix(thunder): update VENDORED.md for clarity on copyright and licensing
Aravinda-HWK May 24, 2026
50699a9
Merge remote-tracking branch 'origin/main' into helm-chart-thunder
Aravinda-HWK May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
cp .env.example .env
```

Edit the `.env` file to set your `DOMAIN` for SSL certificate configuration. Note that SMTP credentials are provided in the API request body, not in this file.
Edit the `.env` file to set your `DOMAIN` for SSL certificate configuration.

5. **Start the API server**
```bash
Expand All @@ -61,15 +61,23 @@
6. **Send a test email**
```bash
curl -k -X POST https://localhost:8443/notify \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{
"smtp_host": "your_smtp_host",
"smtp_port": 587,
"smtp_username": "your_smtp_username",
"smtp_password": "your_smtp_password",
"smtp_sender": "sender@example.com",
"recipient_email": "user@example.com"
"recipients": [
{
"email": "user@example.com",
"name": "User"
}
]
}'
```

The API forwards the bearer access token to SMTP using XOAUTH2.

## Architecture

Expand Down
7 changes: 3 additions & 4 deletions api-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,11 @@ RUN chown -R appuser:appuser /app
# Since we mount them read-only (:ro), this is acceptable
# For production, consider using Docker secrets or adjusting host file permissions

# Expose ports
# 8080 for HTTP, 8443 for HTTPS
EXPOSE 8080 8443
# Expose HTTPS port
EXPOSE 8443

# Set environment variables (can be overridden)
ENV PORT=8080
ENV PORT=8443
ENV CERT_FILE=""
ENV KEY_FILE=""

Expand Down
11 changes: 1 addition & 10 deletions api-server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,9 @@
DOMAIN ?= yourdomain.com
CERT_DIR ?= $(PWD)/../mail-infra/services/silver-config/certbot/keys/etc

.PHONY: run build clean run-https docker-build docker-run docker-run-https docker-stop docker-clean
.PHONY: run build clean docker-build docker-run-https docker-stop docker-clean

run:
go run .

dev:
go run . -dev

run-https:
go run . -port 8443 -cert $(CERT_DIR)/live/$(DOMAIN)/fullchain.pem -key $(CERT_DIR)/live/$(DOMAIN)/privkey.pem

build:
Expand All @@ -23,9 +17,6 @@ clean:
docker-build:
docker build -t pingmailer-api .

docker-run:
docker run -d -p 8080:8080 --name pingmailer-api pingmailer-api

docker-run-https:
docker run -d \
-p 8443:8443 \
Expand Down
43 changes: 27 additions & 16 deletions api-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,23 @@ The API will be available at `https://your-domain:8443/notify`

```bash
curl -X POST https://your-domain:8443/notify \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{
"smtp_host": "smtp-server-container",
"smtp_port": 587,
"smtp_username": "user@yourdomain.com",
"smtp_password": "your-password",
"smtp_sender": "noreply@yourdomain.com",
"recipient_email": "recipient@example.com",
"recipient_name": "John Doe",
"recipients": [
{
"email": "recipient1@example.com",
"name": "John Doe"
},
{
"email": "recipient2@example.com",
"name": "Jane Doe"
}
],
"app_name": "MyApp"
}'
```
Expand All @@ -53,15 +61,18 @@ curl -X POST https://your-domain:8443/notify \
| `smtp_host` | string | SMTP server hostname |
| `smtp_port` | integer | SMTP server port (587 for TLS) |
| `smtp_username` | string | SMTP authentication username |
| `smtp_password` | string | SMTP authentication password |
| `smtp_sender` | string | Sender email address |
| `recipient_email` | string | Recipient's email address |
| `recipients` or `recipient_email` | array or string | Recipient list or a single recipient email (legacy) |

The request must include an `Authorization: Bearer <access-token>` header. The
API forwards this token to SMTP using XOAUTH2 and does not validate it locally.

### Optional Fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `recipient_name` | string | "User" | Recipient's name |
| `recipients` | array | - | List of recipients: `{ "email": "...", "name": "..." }` |
| `app_name` | string | "Application" | Application name for email template |
| `template` | string | - | Custom email template |
| `template_data` | object | - | Data for custom template |
Expand Down Expand Up @@ -97,14 +108,19 @@ Access custom data in your template using `{{.FieldName}}`:

```bash
curl -X POST https://your-domain:8443/notify \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{
"smtp_host": "smtp-server",
"smtp_port": 587,
"smtp_username": "user@domain.com",
"smtp_password": "password",
"smtp_sender": "noreply@domain.com",
"recipient_email": "user@example.com",
"recipients": [
{
"email": "user@example.com",
"name": "John"
}
],
"template": "{{define \"subject\"}}Password Reset{{end}}{{define \"plainBody\"}}Hi {{.Name}}, your code is {{.Code}}{{end}}{{define \"htmlBody\"}}<p>Hi {{.Name}}, your code is <strong>{{.Code}}</strong></p>{{end}}",
"template_data": {
"Name": "John",
Expand All @@ -117,11 +133,12 @@ curl -X POST https://your-domain:8443/notify \

### Success Response

**Status:** `200 OK`
**Status:** `202 Accepted`

```json
{
"message": "Email sent successfully"
"message": "Email queued successfully",
"status": "ok"
}
```

Expand All @@ -148,12 +165,9 @@ curl -X POST https://your-domain:8443/notify \
### Running Locally

```bash
# Run with HTTP (default port 8080)
# Run with HTTPS (requires valid cert/key files)
make run

# Run with HTTPS
make run-https DOMAIN=yourdomain.com

# Build binary
make build
```
Expand All @@ -164,9 +178,6 @@ make build
# Build image
make docker-build

# Run with HTTP
make docker-run

# Run with HTTPS
make docker-run-https DOMAIN=yourdomain.com

Expand Down
18 changes: 6 additions & 12 deletions api-server/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
#!/bin/sh
set -e

# Use positional parameters to safely build the command. This avoids issues with
# word splitting if variables contain spaces or special characters.
set -- ./api-server -port "${PORT}"
# Add OAuth2 introspection URL
if [ -n "${OAUTH2_INTROSPECT_URL}" ]; then
set -- "$@" -oauth2-introspect-url "${OAUTH2_INTROSPECT_URL}"
else
echo "Error: OAUTH2_INTROSPECT_URL must be set" >&2
if [ -z "${CERT_FILE}" ] || [ -z "${KEY_FILE}" ]; then
echo "ERROR: HTTPS is required. Set CERT_FILE and KEY_FILE." >&2
exit 1
fi
# Add TLS configuration if provided
if [ -n "${CERT_FILE}" ] && [ -n "${KEY_FILE}" ]; then
set -- "$@" -cert "${CERT_FILE}" -key "${KEY_FILE}"
fi

# Use positional parameters to safely build the command. This avoids issues with
# word splitting if variables contain spaces or special characters.
set -- ./api-server -port "${PORT}" -cert "${CERT_FILE}" -key "${KEY_FILE}"
exec "$@"
1 change: 0 additions & 1 deletion api-server/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ type Config struct {
CertFile string
KeyFile string
OAuth2 OAuth2Config
Dev bool
}

// OAuth2Config holds the OAuth2 server configuration.
Expand Down
87 changes: 12 additions & 75 deletions api-server/internal/api/auth.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
package api

import (
"encoding/json"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

// IntrospectionResponse represents the OAuth2 token introspection response
type IntrospectionResponse struct {
Active bool `json:"active"`
ClientID string `json:"client_id,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
}
type contextKey string

const accessTokenContextKey contextKey = "access_token"

// extractBearerToken extracts the Bearer token from the Authorization header
func extractBearerToken(r *http.Request) (string, error) {
Expand All @@ -39,60 +26,16 @@ func extractBearerToken(r *http.Request) (string, error) {
return parts[1], nil
}

// validateAccessToken validates the provided access token using OAuth2 token introspection
func (app *App) validateAccessToken(token string) error {
// Create form data with the token
data := url.Values{}
data.Set("token", token)

// #nosec G704 -- IntrospectURL is from server configuration, not user input
req, err := http.NewRequest("POST", app.config.OAuth2.IntrospectURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create introspection request: %w", err)
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

// #nosec G704 -- IntrospectURL is from server configuration, validated at startup
resp, err := app.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to introspect token: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
app.logger.Warn("failed to close response body", "error", closeErr)
}
}()

if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("introspection failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}

var introspection IntrospectionResponse
if err := json.NewDecoder(resp.Body).Decode(&introspection); err != nil {
return fmt.Errorf("failed to decode introspection response: %w", err)
}

// Check if token is active
if !introspection.Active {
return fmt.Errorf("token is not active")
}

// Optionally validate additional claims
if introspection.Exp > 0 && time.Now().Unix() > introspection.Exp {
return fmt.Errorf("token has expired")
func accessTokenFromContext(ctx context.Context) (string, bool) {
token, ok := ctx.Value(accessTokenContextKey).(string)
if !ok || token == "" {
return "", false
}

app.logger.Info("token validated successfully",
"client_id", introspection.ClientID,
"sub", introspection.Sub,
"exp", introspection.Exp)

return nil
return token, true
}

// authMiddleware validates the Bearer token before allowing access to protected endpoints
// authMiddleware extracts the Bearer token and stores it for downstream SMTP XOAUTH2 auth.
// Token validation is intentionally not performed in this API server.
func (app *App) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, err := extractBearerToken(r)
Expand All @@ -102,12 +45,6 @@ func (app *App) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return
}

if err := app.validateAccessToken(token); err != nil {
app.logger.Warn("token validation failed", "error", err)
http.Error(w, "Unauthorized: invalid or expired token", http.StatusUnauthorized)
return
}

next(w, r)
next(w, r.WithContext(context.WithValue(r.Context(), accessTokenContextKey, token)))
}
}
Loading
Loading