Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 82 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Release

on:
workflow_dispatch:
inputs:
version:
description: "Version tag (e.g. v1.2.3)"
required: true

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build binaries
run: |
mkdir dist
platforms=("linux/amd64" "linux/arm64" "windows/amd64" "windows/arm64")
for platform in "${platforms[@]}"; do
GOOS="${platform%/*}"
GOARCH="${platform#*/}"
ext=""
[ "$GOOS" = "windows" ] && ext=".exe"
GOOS=$GOOS GOARCH=$GOARCH go build -o "dist/5000mails-${GOOS}-${GOARCH}${ext}" .
GOOS=$GOOS GOARCH=$GOARCH go build -o "dist/5kmcli-${GOOS}-${GOARCH}${ext}" ./cmd/cli
done

- name: Tag and publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
git tag "${{ inputs.version }}"
git push origin "${{ inputs.version }}"
gh release create "${{ inputs.version }}" \
--title "${{ inputs.version }}" \
--generate-notes \
dist/* \
static/confirm.md \
static/template.html \
static/theme.example.css

docker:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v4

- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV"

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest


20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Test

on:
pull_request:
push:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Run tests
run: go test ./...
39 changes: 39 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work
go.work.sum

# env file
.env

# Editor/IDE
.idea/
.vscode/

# Files marked local
*.local.*

# development artifacts
5000mails.db
5kmcli
243 changes: 243 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# Public API

All responses: `Content-Type: application/json`

---

## POST `/{listName}/subscribe`

Subscribe a user to the named mailing list. Triggers a double opt-in confirmation email.

**Path params**
- `listName` — name of the mailing list

**Body** — `application/json` or `application/x-www-form-urlencoded`

| Field | Type | Required |
|---------|--------|----------|
| `name` | string | yes |
| `email` | string | yes |

**Responses**

| Status | Meaning |
|--------|------------------------|
| `202` | Confirmation email sent |
| `400` | Missing/invalid fields |
| `500` | Internal error |

---

## GET `/confirm/{token}`

Complete double opt-in using the token from the confirmation email.

**Path params**
- `token` — 64-char hex token

**Responses**

| Status | Meaning |
|--------|----------------------------------|
| `200` | Subscription confirmed |
| `400` | Token invalid or already used |

---

## GET `/unsubscribe/{token}`

Remove a subscriber using their per-subscription unsubscribe token (included in every newsletter).

**Path params**
- `token` — 64-char hex unsubscribe token (unique per subscription)

**Responses**

| Status | Meaning |
|--------|----------------------------------|
| `200` | Unsubscribed |
| `400` | Token invalid or not found |

---

# Management API

Grants full access to sending mails and managing lists. A cli client is provided. Third-party frontends should be reasonably easy to set up by using the following documentation.

All management endpoints require Ed25519 request signing when a public key is configured on the server.
This API should not be exposed publicly, even if it is authenticated. Prefer some kind of private tunneling/VPN, or using it right on the machine the server runs on via ssh.
The request signing is intended to be additional hardening, not the main security measure.

**Required headers**

| Header | Value |
|---------------|----------------------------------------------------------------------------|
| `X-Timestamp` | Unix timestamp (seconds) of the request |
| `X-Signature` | Hex-encoded Ed25519 signature over `timestamp\nMETHOD\npath\nbodyHash` |

The signed message is: `<timestamp>\n<METHOD>\n<path>\n<sha256(body) as hex>`

Requests whose timestamp differs from the server's clock by more than 5 minutes are rejected.

---

## POST `/lists`

Create a new mailing list.

**Body** — `application/json`

| Field | Type | Required |
|--------|--------|----------|
| `name` | string | yes |

**Responses**

| Status | Meaning |
|--------|----------------------|
| `201` | List created |
| `400` | Missing/invalid name |
| `500` | Internal error |

**Response body**
```json
{ "id": 1, "name": "my-list" }
```

---

## GET `/lists/{id}`

Get list details including subscriber counts.

**Path params**
- `id` — numeric list ID

**Responses**

| Status | Meaning |
|--------|----------------|
| `200` | List details |
| `400` | Invalid ID |
| `404` | List not found |

**Response body**
```json
{
"id": 1,
"name": "my-list",
"subscribers": { "total": 42, "confirmed": 38 }
}
```

---

## PUT `/lists/{id}`

Rename a mailing list.

**Path params**
- `id` — numeric list ID

**Body** — `application/json`

| Field | Type | Required |
|--------|--------|----------|
| `name` | string | yes |

**Responses**

| Status | Meaning |
|--------|----------------------|
| `200` | Renamed list |
| `400` | Missing/invalid name |
| `500` | Internal error |

---

## DELETE `/lists/{id}`

Delete a mailing list and all its subscribers.

**Path params**
- `id` — numeric list ID

**Responses**

| Status | Meaning |
|--------|----------------|
| `204` | Deleted |
| `400` | Invalid ID |
| `500` | Internal error |

---

## GET `/lists/{id}/users`

List all subscribers of a mailing list.

**Path params**
- `id` — numeric list ID

**Responses**

| Status | Meaning |
|--------|-----------------|
| `200` | Subscriber list |
| `400` | Invalid ID |
| `500` | Internal error |

**Response body**
```json
[
{ "id": 1, "name": "Alice", "email": "alice@example.com", "confirmed": true },
{ "id": 2, "name": "Bob", "email": "bob@example.com", "confirmed": false }
]
```

---

## POST `/lists/{name}/send`

Render a markdown newsletter and send it to all confirmed subscribers of the named list.

**Path params**
- `name` — list name

**Body** — `application/json`

| Field | Type | Required | Description |
|--------|--------|----------|-----------------------------------------------|
| `raw` | string | yes | Raw markdown content of the mail |
| `data` | object | no | Template variables injected into the markdown |

**Responses**

| Status | Meaning |
|--------|-----------------|
| `200` | Mail dispatched |
| `400` | Missing `raw` |
| `500` | Internal error |

---

## POST `/mail/test`

Send a rendered test mail to a single recipient without touching any list.

**Body** — `application/json`

| Field | Type | Required | Description |
|-------------------|--------|----------|-------------------------|
| `recipient.name` | string | no | Recipient display name |
| `recipient.email` | string | yes | Recipient email address |
| `raw` | string | yes | Raw markdown content |
| `data` | object | no | Template variables |

**Responses**

| Status | Meaning |
|--------|-----------------------------------|
| `200` | Test mail sent |
| `400` | Missing `recipient.email` or `raw` |
| `500` | Internal error |
Loading
Loading