# LUVEEDU Email Manager API — Documentation

**Version:** 1.0.0 · **Base URL:** `https://email.luveedu.cloud` · **Transport:** HTTPS only · **Body format:** `application/json`

A Brevo/SendGrid-style **bulk email infrastructure**. Customers verify a sending domain once, then send DKIM-signed emails through the client API. Every email costs **1 credit** from the owner's balance; failed deliveries are auto-refunded.

> **AI AGENTS: READ THIS FIRST**
> This document is deterministic and matches the running service exactly.
>
> **Two separate interfaces, two different keys:**
> 1. **CLIENT interface** — your customers' apps call `POST /api/send`, `GET /api/status/{id}` with a **customer API key** (`emailmgr_xxxx...`).
> 2. **ADMIN interface** — only your panel calls these, from an **allowlisted IP**, with the **admin key** below, to provision domains/keys/credits and read analytics.
>
> A customer CANNOT send until: (1) their domain was added via admin API, (2) they published its DNS records, (3) `/api/verify-domain` returned `"verified": true`.

```yaml
base_url: https://email.luveedu.cloud
api_prefix: /api
admin_key_header: X-API-Key
admin_api_key: emailmgr_admin_b75a3a3730bf31904b5d8e34177b7870cafa9e82196decc3
admin_ip_allowlist: [171.50.171.29, 188.245.148.184, 157.90.244.133]
client_key_format: emailmgr_<4hex><40hex>     # shown ONCE at creation
delivery_path: API -> DKIM(rsa-sha256) -> Postfix -> Internet MX
credit_model: 1 email = 1 credit; failed delivery = auto-refund
max_recipients_per_request: 50
public_endpoints: [GET /api/health]
```

---

## 1. Interface 1 — CLIENT (customer API key)

All client endpoints require header:

```
X-API-Key: emailmgr_6bc1154f...   (the key your panel generated for this user)
```

The key is bound to one `(user_id, username)` pair — every action is scoped to it.

### 1.1 `POST /api/send`

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `from` | string | ✅ | `"Name <you@yourdomain.com>"` or bare address. The domain MUST belong to this key's owner AND be `verified`. |
| `to` | string \| string[] | ✅ | One address or array. Max **50** per request. |
| `subject` | string | ✅ | Plain text subject. |
| `text` | string | one of | Plain-text body |
| `html` | string | text/html | HTML body (multipart/alternative sent when both given) |

**Per-recipient logic:** balance is debited atomically per recipient *before* queueing. If credits run out mid-request, remaining recipients are returned in `rejected` with `reason:"insufficient_balance"` (also recorded, never charged).

**Response `200`:**

```json
{
  "queued":   [ {"id": 12, "to": "user@example.com"}, {"id": 13, "to": "other@example.com"} ],
  "rejected": [ {"to": "x@y.com", "reason": "insufficient_balance"} ],
  "balance_left": 8
}
```

`queued[].id` → poll via status endpoint. Typical latency to `sent`: < 5 s.

### 1.2 `GET /api/status/{id}`

Owner-scoped (foreign ids → `404`). Response:

```json
{
  "id": 12, "status": "sent",
  "from": "test@luveedu.me", "to": "dest@example.com",
  "subject": "...", "error": null,
  "created_at": "2026-08-24T18:47:47", "sent_at": "2026-08-24T18:47:49"
}
```

`status` ∈ `queued` → `sent` | `failed` (failure reason in `error`; credit refunded automatically).

### 1.3 `GET /api/me`

Key info + current balance:
`{"name","key_prefix","user_id","username","balance"}`

### 1.4 `GET /api/status` — restriction status

Tells the customer whether their API key / IP / any owned domain is currently
rate-limit blocked:

```json
{
  "status": "normal",              // "restricted" | "normal"
  "restricted": false,
  "blocks": [],                    // active blocks: {scope, id, reason, until, retry_after, message}
  "balance": 7,
  "send_rate_limit": {
    "requests_per_minute": 200,
    "scope": "per API key and per sender domain",
    "block_duration_seconds": 300
  }
}
```

---

## 2. Interface 2 — ADMIN (panel only)

Requires **both**:

```
X-API-Key: emailmgr_admin_b75a3a3730bf31904b5d8e34177b7870cafa9e82196decc3
Caller IP ∈ {171.50.171.29, 188.245.148.184, 157.90.244.133}      (else 403)
```

### 2.1 Domain lifecycle

**`POST /api/add-domain`**

```json
{ "domain": "customer.com", "user_id": "7", "username": "john" }
```

→ `200` with exactly **TWO CNAME records** for the customer to publish at their DNS provider (Resend-style onboarding — no raw keys, no TXT blobs). **The values are static and IDENTICAL for every domain — they never change:**

```json
{
  "id": 3, "domain": "customer.com", "status": "pending",
  "dns_records": {
    "cnames": [
      {"type":"CNAME","name":"email.customer.com",
       "value":"smtp.luveedu.me",
       "purpose":"ownership + Return-Path (bounces) + SPF"},
      {"type":"CNAME","name":"luveedu._domainkey.customer.com",
       "value":"luveedu._domainkey.smtp.luveedu.me",
       "purpose":"DKIM public key (server-standard shared key)"}
    ],
    "optional": {
      "spf_root": {"type":"TXT","name":"@ (customer.com)","value":"v=spf1 include:_spf.luveedu.cloud ~all"},
      "dmarc_suggested": {"type":"TXT","name":"_dmarc.customer.com","value":"v=DMARC1; p=none; …"}
    }
  },
  "message": "Add BOTH CNAME records at your DNS provider (values are static and never change), then call /api/verify-domain"
}
```

DKIM uses **one standard server-wide key** (selector `luveedu`, SendGrid-style shared pool). The public key lives on `luveedu._domainkey.smtp.luveedu.me`, a host we fully control — customers only ever add the two CNAMEs above and never touch key material. Duplicate domain → `409`.

**`POST /api/verify-domain`** — same body as add-domain. Verifies **both CNAMEs resolve publicly to the expected targets** (this also proves domain ownership). Returns `{verified: bool, dkim_record_found: bool, status}`; status becomes `verified` only when both pass.

**`POST /api/delete-domain`** — same body. Removes instantly; sends from it stop with `403`.

**`GET /api/list-domains?user_id=&username=`** — filters optional.

### 2.2 API keys & balance

**`POST /api/create-apikey`** `{name, user_id, username}` →

```json
{"id": 5, "key_prefix": "emailmgr_ab12", "api_key": "emailmgr_ab12<40hex>",
 "warning": "Store this key now - it is shown only once"}
```
Creates a zero-balance wallet for the user if none exists.

**`POST /api/delete-apikey`** `{key_id}` → soft-deactivates. Client calls then return `401`.

**`GET /api/list-apikeys?user_id=&username=`** → keys incl. live `balance`.

**`GET /api/balance?user_id=7&username=john`**
→ `{"balance": 120, "total_added": 150}`

**`POST /api/add-balance`** `{user_id, username, amount, note?}`
- Positive = top-up. Negative = deduction (rejected with `400` if it would go below zero).
- Every movement is written to `balance_ledger`; failed deliveries appear as `+1 "auto-refund: failed delivery"`.

### 2.3 `GET /api/analytics?period=today|yesterday|week|month|year[&user_id=]`

```json
{
  "period": "today", "start": "...", "end": "...",
  "totals": {
    "submitted": 42, "sent": 40, "failed": 2,
    "rejected_insufficient_balance": 5,
    "success_rate": 95.24
  },
  "per_domain": {"customer.com": {"sent": 30, "failed": 1}}
}
```

`yesterday` covers that full calendar day (UTC); others are rolling windows ending now.

### 2.4 `GET /api/health` — public

`{"status":"healthy","service":"email-manager-api","version":"1.0.0","timestamp":"..."}`

### 2.5 `POST /api/status-client`

Restriction status for ONE customer (by identity, not by key). Body:

```json
{ "user_id": "7", "username": "john" }
```

→

```json
{
  "user_id": "7", "username": "john",
  "status": "normal",              // "restricted" | "normal"
  "restricted": false,
  "blocks": [],                    // active blocks on any of the customer's API keys or domains
  "balance": 42,
  "api_keys": { "total": 2, "active": 2 },
  "domains": [ { "domain": "customer.com", "status": "verified" } ],
  "last_24h": { "sent": 120, "failed": 3 }
}
```

---

## 3. Error responses (exact shapes)

```json
{ "error": "<message>", "status_code": <code> }
```

| Code | Trigger |
|------|---------|
| `400` | Bad body / invalid domain or address / no body content / bad period / overdraft deduction |
| `401` | Missing or invalid key (client or admin) |
| `403` | Admin call from non-allowlisted IP · `from`-domain not verified for this owner · blocked domain |
| `404` | Unknown email id for this owner · unknown domain/key for this owner |
| `409` | Domain already added |

CORS: all origins; headers `X-API-Key, Content-Type`; methods `GET, POST, OPTIONS`.

---

## 4. Standard Integration Algorithm (for AI agents)

```
PANEL SIDE (admin key):
1. POST /api/add-domain        → show the TWO CNAMEs to the customer
2. Customer publishes both     → POST /api/verify-domain (repeatable)
3. POST /api/create-apikey     → show api_key ONCE
4. POST /api/add-balance       → sell credits

CUSTOMER SIDE (their api key):
5. POST /api/send {from(anyone@verified-domain), to[], subject, html/text}
   - visible From:        anyone@customer.com
   - Return-Path:         bounce@email.customer.com  (bounces come to us)
   - DKIM:                d=customer.com (aligned, DMARC ✅)
6. Poll GET /api/status/{queued[].id} until status != queued
7. Watch GET /api/me for balance; top up via panel when low
```

### cURL — full happy path

```bash
AK="emailmgr_admin_b75a3a3730bf31904b5d8e34177b7870cafa9e82196decc3"
B="https://email.luveedu.cloud/api"

# 1) domain + records (panel)
curl -s -X POST $B/add-domain -H "X-API-Key: $AK" -H "Content-Type: application/json" \
  -d '{"domain":"customer.com","user_id":"7","username":"john"}'
# 2) after customer published DNS:
curl -s -X POST $B/verify-domain -H "X-API-Key: $AK" -H "Content-Type: application/json" \
  -d '{"domain":"customer.com","user_id":"7","username":"john"}'
# 3) key + credits
curl -s -X POST $B/create-apikey -H "X-API-Key: $AK" -H "Content-Type: application/json" \
  -d '{"name":"john-main","user_id":"7","username":"john"}'
CK="<paste returned api_key>"
curl -s -X POST $B/add-balance -H "X-API-Key: $AK" -H "Content-Type: application/json" \
  -d '{"user_id":"7","username":"john","amount":1000,"note":"starter pack"}'

# 4) customer sends (any server allowed; key is the only credential)
curl -s -X POST $B/send -H "X-API-Key: $CK" -H "Content-Type: application/json" \
  -d '{"from":"John <john@customer.com>","to":["a@x.com","b@y.com"],
       "subject":"Welcome","html":"<h1>Hello</h1>","text":"Hello"}'

# 5) status + analytics
curl -s "$B/status/12" -H "X-API-Key: $CK"
curl -s "$B/analytics?period=today" -H "X-API-Key: $AK"
```

---

## 5. Deliverability notes

- Every message is **DKIM-signed** (rsa-sha256) with the server's **one standard 2048-bit shared key** (selector `luveedu` — SendGrid-style pool). Customers never handle keys: they CNAME `luveedu._domainkey.<domain>` → `luveedu._domainkey.smtp.luveedu.me`, giving **DMARC-aligned** `d=<their domain>`.
- Return-Path uses `bounce@email.<domain>` (via the first CNAME), so SPF and bounces resolve through our infrastructure.
- DMARC: suggested record provided; policy upgrade to `quarantine/reject` recommended after monitoring.
- **Server operator TODO for best inbox placement:** set rDNS/PTR `mail.luveedu.cloud` → `188.245.148.184` in the Hetzner Cloud Console.
- Bounce (NDR) parsing is not yet automated; synchronous SMTP rejections mark the email `failed` immediately with the remote server's response in `error`.

---

## 6. Rate limits & abuse protection

All limits are enforced app-side and return **`429`** with a `Retry-After`
header and body `{error, status_code, reason, retry_after}`.

| Tier | Trigger | Scope | Action |
|---|---|---|---|
| DoS guard | ≥100 req/s sustained for ≥10 s (any endpoint) | IP | blocked **1 hour** |
| Attacker guard | >10 failed requests/min (missing/wrong API key, wrong or unverified sender domain, malformed send bodies) | IP | blocked **1 hour** |
| Send abuse | >200 `POST /api/send` requests/min | per **API key** AND per **sender domain** | `429` + that key/domain blocked **5 minutes** |

Notes:
- A single `POST /api/send` may carry up to 50 recipients; the limit counts
  *requests*, so batching recipients is always more efficient than looping.
- Blocked callers can check `GET /api/status` (client key) to see exactly what
  is blocked, why, and until when; panels use `POST /api/status-client`.
- Blocks are in-memory: restarting the service clears them.

---

## 7. Using your own DNS provider (external DNS)

Verification is **provider-agnostic**: the API resolves both CNAME records via
public resolvers (1.1.1.1 / 8.8.8.8), so it does not matter where the domain's
DNS is hosted — our DNS manager, Cloudflare, GoDaddy, Route53, anywhere.

A customer on ANY provider does exactly this:

1. Add the two static CNAME records (same values for everyone):
   - `email.<domain>` → `smtp.luveedu.me`
   - `luveedu._domainkey.<domain>` → `luveedu._domainkey.smtp.luveedu.me`
2. Wait for propagation (usually seconds to a few minutes).
3. Panel calls `/api/verify-domain` → both CNAMEs are checked publicly →
   `verified`.

Optional (recommended, any provider): root TXT `v=spf1 include:_spf.luveedu.cloud ~all`
and `_dmarc.<domain>` TXT with the suggested DMARC record from §2.1.

Cloudflare users: create the CNAMEs with **proxy OFF (grey cloud / "DNS only")** —
CNAME flattening at the apex is fine, but mail records must resolve directly.
