Skip to content

Commit 68d39d5

Browse files
Sebastian VargasSebastian Vargas
authored andcommitted
Initial commit
- README and CONTRIBUTING with format, HTML rules, variable conventions - MIT license - Empty manifest (index.json) and templates/ directory ready for content - Local + CI validator (scripts/validate.py) covering schema, file presence, size limits, kebab-case ids, URL conventions, no-script HTML rule - GitHub Actions workflow runs validator on every PR and push to main
0 parents  commit 68d39d5

7 files changed

Lines changed: 303 additions & 0 deletions

File tree

.github/workflows/validate.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Validate
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
validate:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.x'
17+
- run: python3 scripts/validate.py

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.DS_Store
2+
*.swp
3+
*.swo
4+
__pycache__/
5+
*.pyc
6+
.vscode/
7+
.idea/

CONTRIBUTING.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Contributing a template
2+
3+
Thanks for adding to the library — this is how the SendDock community gets more starter templates.
4+
5+
**Rule of thumb: one template per PR.** Easier to review, easier to revert, easier to credit.
6+
7+
## Quick start
8+
9+
1. Fork this repo and check out `main`.
10+
2. Pick a short, kebab-case `id` for your template (e.g. `welcome-minimal`, `newsletter-monthly-digest`).
11+
3. Add three things:
12+
- `templates/<id>.html` — the email body
13+
- `templates/<id>.png` — the 600×400 preview thumbnail
14+
- One new entry in `index.json` describing it
15+
4. Run `python3 scripts/validate.py` locally to catch errors before pushing.
16+
5. Open a PR.
17+
18+
## HTML rules
19+
20+
| Rule | Why |
21+
|---|---|
22+
| No `<script>` tags | Email clients strip them anyway. Anything that needs JS doesn't belong in a template. |
23+
| No external assets | Images, fonts, and CSS must be inlined or hosted on `raw.githubusercontent.com` inside this repo. Otherwise the template breaks if the external host goes away or starts blocking hotlinks. |
24+
| Inline CSS only | Most email clients (Gmail, Outlook) strip `<style>` blocks or `<link>` tags. Use the `style=""` attribute. |
25+
| Mobile responsive | Use a fluid layout (max-width tables, percentage widths). Test in a phone-width preview before submitting. |
26+
| Works in dark mode | Avoid pure white backgrounds and pure black text. Use `#fafafa`/`#171717` or similar so it stays legible when the client inverts colors. |
27+
| Include unsubscribe link | Add `<a href="{{unsubscribe_url}}">Unsubscribe</a>` in the footer. SendDock requires this for compliance. |
28+
29+
## Variables
30+
31+
Templates use [Handlebars](https://handlebarsjs.com) syntax. SendDock provides these built-in variables on every send:
32+
33+
| Variable | What it resolves to |
34+
|---|---|
35+
| `{{name}}` | Subscriber's name |
36+
| `{{email}}` | Subscriber's email address |
37+
| `{{subscriber_id}}` | Internal ID (useful for tracking links) |
38+
| `{{unsubscribe_url}}` | One-click unsubscribe URL (RFC 8058 compatible) |
39+
40+
You can also use any custom variable you want (e.g. `{{first_name}}`, `{{company_name}}`, `{{cta_url}}`). Declare each one in the `variables` array of your `index.json` entry so the editor can show them as hints.
41+
42+
## Thumbnail specs
43+
44+
- **Dimensions**: 600×400 pixels
45+
- **Format**: PNG
46+
- **Max size**: 100 KB (compress with [TinyPNG](https://tinypng.com) or `pngquant` if larger)
47+
- **Content**: render the template in a real email client (or a browser preview), screenshot it, crop to 600×400
48+
49+
The thumbnail is what people see in the gallery before they click — make it look like the actual rendered email, not a cropped fragment.
50+
51+
## `index.json` schema
52+
53+
Each entry in the `templates` array looks like this:
54+
55+
```json
56+
{
57+
"id": "welcome-minimal",
58+
"name": "Welcome — minimal",
59+
"category": "welcome",
60+
"description": "Clean welcome email with a single CTA. Works for SaaS onboarding and newsletter sign-ups.",
61+
"thumbnail_url": "https://raw.githubusercontent.com/Arkhe-Systems/senddock-templates/main/templates/welcome-minimal.png",
62+
"html_url": "https://raw.githubusercontent.com/Arkhe-Systems/senddock-templates/main/templates/welcome-minimal.html",
63+
"variables": ["first_name", "company_name", "cta_url"]
64+
}
65+
```
66+
67+
### Field rules
68+
69+
| Field | Rules |
70+
|---|---|
71+
| `id` | kebab-case, must match the HTML and PNG filenames |
72+
| `name` | Short display name (≤ 40 chars). Use em-dashes for subtitles: `Newsletter — monthly digest` |
73+
| `category` | One of: `welcome`, `newsletter`, `announcement`, `digest`, `transactional` |
74+
| `description` | One sentence (≤ 140 chars). What is it for, who would use it. |
75+
| `thumbnail_url` | Must point at `templates/<id>.png` in this repo |
76+
| `html_url` | Must point at `templates/<id>.html` in this repo |
77+
| `variables` | Custom variables the template references. Don't list the built-in ones. |
78+
79+
## Categories
80+
81+
Right now we have five. Open an issue first if you think we need a new one — adding categories is a coordination decision, not a per-template choice.
82+
83+
- **welcome** — first email after signup
84+
- **newsletter** — recurring content broadcasts
85+
- **announcement** — product launches, feature releases, milestones
86+
- **digest** — periodic summaries (weekly, monthly)
87+
- **transactional** — receipts, password resets, verification codes
88+
89+
## Local validation
90+
91+
Before opening a PR:
92+
93+
```bash
94+
python3 scripts/validate.py
95+
```
96+
97+
This is the same script CI runs. It checks:
98+
99+
- `index.json` parses and has the required top-level fields
100+
- Every template entry has all required fields with valid values
101+
- Every `id` has a matching `.html` and `.png` file
102+
- HTML files don't contain `<script>` tags
103+
- PNG files are under 100 KB
104+
- URLs match the expected `raw.githubusercontent.com` pattern
105+
106+
## Review process
107+
108+
- Two checks must pass: CI validation and a maintainer review.
109+
- Maintainers will test-render your template in SendDock before merging.
110+
- Tweaks may be requested for HTML compatibility (Gmail and Outlook are picky).
111+
- Once merged, your template is live in every SendDock instance within an hour.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Arkhe Systems
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SendDock Templates
2+
3+
Community-maintained library of starter email templates for [SendDock](https://senddock.dev).
4+
5+
Every SendDock instance (Cloud and self-hosted) loads this manifest at runtime to power the **Browse library** modal on the templates page. Pick a template, click *Use template*, and SendDock clones it into your project — ready to edit.
6+
7+
```
8+
GET https://raw.githubusercontent.com/Arkhe-Systems/senddock-templates/main/index.json
9+
```
10+
11+
## How it works
12+
13+
- `index.json` — manifest with metadata for every template (id, name, category, thumbnail, html URL, declared variables).
14+
- `templates/<id>.html` — the actual HTML body. Must use Handlebars variables.
15+
- `templates/<id>.png` — the preview thumbnail shown in the gallery.
16+
17+
SendDock backends cache the manifest in Redis for 1 hour, so changes here propagate to all instances within an hour without any redeploy.
18+
19+
## Contributing
20+
21+
PRs are open and welcome — one PR per template. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the format, HTML rules, thumbnail specs, and review checklist.
22+
23+
This is the only SendDock repo that accepts external code contributions. The core engine at [Arkhe-Systems/senddock](https://github.com/Arkhe-Systems/senddock) is core-team-only by design.
24+
25+
## License
26+
27+
All templates and code in this repo are released under the [MIT License](./LICENSE). Use them anywhere, modify freely, no attribution required.
28+
29+
SendDock itself is AGPL-3.0.

index.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"version": 1,
3+
"templates": []
4+
}

scripts/validate.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import re
4+
import sys
5+
from pathlib import Path
6+
7+
REPO_BASE_URL = "https://raw.githubusercontent.com/Arkhe-Systems/senddock-templates/main"
8+
ALLOWED_CATEGORIES = {"welcome", "newsletter", "announcement", "digest", "transactional"}
9+
ID_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
10+
MAX_THUMBNAIL_BYTES = 100 * 1024
11+
MAX_NAME_CHARS = 40
12+
MAX_DESCRIPTION_CHARS = 140
13+
REQUIRED_FIELDS = {"id", "name", "category", "description", "thumbnail_url", "html_url", "variables"}
14+
BUILTIN_VARIABLES = {"name", "email", "subscriber_id", "unsubscribe_url"}
15+
SCRIPT_TAG = re.compile(r"<script[\s>]", re.IGNORECASE)
16+
17+
18+
def main() -> None:
19+
repo_root = Path(__file__).resolve().parent.parent
20+
manifest_path = repo_root / "index.json"
21+
errors: list[str] = []
22+
23+
try:
24+
manifest = json.loads(manifest_path.read_text())
25+
except (json.JSONDecodeError, OSError) as e:
26+
print(f"ERROR: cannot parse index.json: {e}")
27+
sys.exit(1)
28+
29+
if not isinstance(manifest, dict) or "version" not in manifest or "templates" not in manifest:
30+
errors.append("index.json: top-level must have `version` and `templates`")
31+
if not isinstance(manifest.get("templates"), list):
32+
errors.append("index.json: `templates` must be an array")
33+
34+
seen_ids: set[str] = set()
35+
for i, entry in enumerate(manifest.get("templates", [])):
36+
prefix = f"templates[{i}]"
37+
if not isinstance(entry, dict):
38+
errors.append(f"{prefix}: must be an object")
39+
continue
40+
41+
missing = REQUIRED_FIELDS - set(entry.keys())
42+
if missing:
43+
errors.append(f"{prefix}: missing fields: {sorted(missing)}")
44+
continue
45+
46+
eid = entry["id"]
47+
if not isinstance(eid, str) or not ID_PATTERN.match(eid):
48+
errors.append(f"{prefix}: id must be kebab-case (got {eid!r})")
49+
continue
50+
if eid in seen_ids:
51+
errors.append(f"{prefix}: duplicate id {eid!r}")
52+
seen_ids.add(eid)
53+
54+
prefix = f"templates[{i}] ({eid})"
55+
56+
name = entry["name"]
57+
if not isinstance(name, str) or not name or len(name) > MAX_NAME_CHARS:
58+
errors.append(f"{prefix}: name must be a non-empty string ≤ {MAX_NAME_CHARS} chars")
59+
60+
if entry["category"] not in ALLOWED_CATEGORIES:
61+
errors.append(f"{prefix}: category must be one of {sorted(ALLOWED_CATEGORIES)}")
62+
63+
description = entry["description"]
64+
if not isinstance(description, str) or not description or len(description) > MAX_DESCRIPTION_CHARS:
65+
errors.append(f"{prefix}: description must be a non-empty string ≤ {MAX_DESCRIPTION_CHARS} chars")
66+
67+
expected_html = f"{REPO_BASE_URL}/templates/{eid}.html"
68+
expected_thumb = f"{REPO_BASE_URL}/templates/{eid}.png"
69+
if entry["html_url"] != expected_html:
70+
errors.append(f"{prefix}: html_url must be {expected_html}")
71+
if entry["thumbnail_url"] != expected_thumb:
72+
errors.append(f"{prefix}: thumbnail_url must be {expected_thumb}")
73+
74+
variables = entry["variables"]
75+
if not isinstance(variables, list) or not all(isinstance(v, str) for v in variables):
76+
errors.append(f"{prefix}: variables must be an array of strings")
77+
else:
78+
for v in variables:
79+
if v in BUILTIN_VARIABLES:
80+
errors.append(f"{prefix}: variable {v!r} is built-in, don't declare it")
81+
82+
html_path = repo_root / "templates" / f"{eid}.html"
83+
thumb_path = repo_root / "templates" / f"{eid}.png"
84+
85+
if not html_path.is_file():
86+
errors.append(f"{prefix}: missing file templates/{eid}.html")
87+
else:
88+
html = html_path.read_text(errors="replace")
89+
if SCRIPT_TAG.search(html):
90+
errors.append(f"{prefix}: html contains a <script> tag")
91+
92+
if not thumb_path.is_file():
93+
errors.append(f"{prefix}: missing file templates/{eid}.png")
94+
elif thumb_path.stat().st_size > MAX_THUMBNAIL_BYTES:
95+
kb = thumb_path.stat().st_size // 1024
96+
errors.append(f"{prefix}: thumbnail is {kb}KB, max is {MAX_THUMBNAIL_BYTES // 1024}KB")
97+
98+
templates_dir = repo_root / "templates"
99+
if templates_dir.is_dir():
100+
for file in templates_dir.iterdir():
101+
if file.suffix in {".html", ".png"} and file.stem not in seen_ids:
102+
errors.append(f"orphan file: templates/{file.name} has no entry in index.json")
103+
104+
if errors:
105+
print("Validation failed:")
106+
for err in errors:
107+
print(f" - {err}")
108+
sys.exit(1)
109+
110+
print(f"OK — {len(seen_ids)} template(s) valid")
111+
112+
113+
if __name__ == "__main__":
114+
main()

0 commit comments

Comments
 (0)