FreeSupp is a minimal email based support system that provides:
That's it. Messages are delivered via email, operators log in with email + password.
It's very simple technically as well:
FreeSupp needs a public HTTP(S) URL: conversation links and the widget iframe are built from it. Operator session cookies are marked Secure whenever BASE_URL is https.
docker run -d --name freesupp \
-p 127.0.0.1:8080:8080 \
-v freesupp-data:/data \
-e BASE_URL="https://support.example.com" \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e SMTP_HOST="email-smtp.eu-central-1.amazonaws.com" \
-e SMTP_USER="..." \
-e SMTP_PASSWORD="..." \
-e MAIL_FROM="support@example.com" \
-e USE_PROXY="true" \
ghcr.io/kooler/freesupp:latest
Is is strongly recommended to not serve it via HTTP but rather put a reverse proxy (like Nginx or Caddy) in front of it. When using proxyu make sure to specify USE_PROXY="true" otherwise the rate limiting will use internal address and thus overlimit.
There is also sample docker-compose.yml for the reference.
Once all is up and running:
https://support.example.com/ (if you are running locally http://localhost:8080). Since no users exist yet, you will be requested to create the first account.http://localhost:9090/widget/).There are two types of users: normal and admins. Admins can manage users and appoint new admins, normal users can't. The rest is the same.
The first created user (requested after the installation) becomes the admin.
In regards to icoming messages -- everyone can read and answer any conversation.
Put this before </body> on any page that should show the bubble:
<script src="https://support.example.com/widget.js" defer></script>
Script injects a fixed-position bubble button that toggles the contact form.
Optional attributes on the <script> tag:
| Attribute | Default | Purpose |
|---|---|---|
data-label | Contact support | Accessible label / tooltip on the bubble |
data-color | #2563eb | Bubble background colour |
data-base-url | script's own origin | Override when the script is served from a CDN but the API lives elsewhere |
data-min-length | — | Shortest message the form accepts, in characters. Unset means the length is not checked |
data-trigger | — | CSS selector for your own button; see Your own button |
Both may also be set through window.freesuppSettings (see below); that object has higher prio wherever both script attributes and the object are specified.
<script src="https://support.example.com/widget.js"
data-label="Need help?" data-color="#111827" defer></script>
If you would rather not use the bubble, link visitors straight to https://support.example.com/widget/ — the form works as a standalone page.
Anything you put in window.freesuppSettings.metadata is attached to the message and shown beside the conversation in the Inbox. This is a good place for user information, browser they use, account info, etc.
<script>
window.freesuppSettings = {
minLength: 30,
metadata: {
Account: 'acme-inc',
Plan: 'pro',
Page: location.href,
Browser: navigator.userAgent,
},
};
</script>
<script src="https://support.example.com/widget.js" defer></script>
Values are sent as text: numbers and booleans are converted, nested objects, arrays and blank values are dropped. Keys longer than 64 characters are ignored, values longer than 500 characters are trimmed, and at most 30 entries are kept per message.
The sidebar is ordered alphabetically by key, not in the order you declare them.
Every message carries its own copy, taken when it was sent, so a thread that outlives a plan change or a switch of browser keeps both. The sidebar shows the most recent message that carried anything; a message with metadata is marked · N details in the thread, and clicking it shows what that one carried.
Set data-trigger (or window.freesuppSettings.trigger) a CSS selector to use a custom button instead of the default bubble.
<button id="help">Contact support</button>
<script src="https://support.example.com/widget.js" data-trigger="#help" defer></script>
The selector is matched at click time. Unlike minLength and metadata, the trigger must be set before the script runs as it decides whether to draw the bubble at all. Put window.freesuppSettings.trigger in a plain <script> block ahead of the widget tag, or use data-trigger.
Required:
| Variable | Description |
|---|---|
BASE_URL | Public URL of this deployment, e.g. https://support.example.com. Must start with http:// or https://; a trailing slash is trimmed. |
SESSION_SECRET | Random string used to sign operator session cookies. At least 16 characters; generate once with openssl rand -hex 32 and keep it secret — changing it signs everyone out. |
Optional:
| Variable | Default | Description |
|---|---|---|
LISTEN | :8080 | Listen address. |
DB_PATH | /data/freesupp.db | SQLite file. Put it on a volume. |
SMTP_HOST | — | Outbound mail server. When empty, no mail is sent — bodies are logged instead (dev mode). |
SMTP_PORT | 587 | 465 uses implicit TLS; any other port opportunistically upgrades via STARTTLS. |
SMTP_USER | — | SMTP username. PLAIN auth is used only when this is set. |
SMTP_PASSWORD | — | SMTP password. |
MAIL_FROM | — | Sender address. Required once SMTP_HOST is set. |
TURNSTILE_SITE_KEY | — | Cloudflare Turnstile site key. |
TURNSTILE_SECRET | — | Cloudflare Turnstile secret. Must be set together with the site key; leave both empty to disable the captcha. |
USE_PROXY | false | Take the visitor's IP from the last X-Forwarded-For entry — the one your proxy appended — instead of the socket peer. Enable it only when a reverse proxy is the sole route to this process — otherwise anyone can supply the header and mint a fresh rate-limit bucket per request. |
TZ | UTC | Standard container timezone; affects log timestamps only. Stored timestamps are always UTC. |
FreeSupp works with plain SMTP, so any provider supporting it would work.
For AWS SES you'd need to create SMTP credentials:
MAIL_FROM address.SMTP_HOST=email-smtp.<region>.amazonaws.com, SMTP_PORT=587,
SMTP_USER/SMTP_PASSWORD to those credentials, and MAIL_FROM to the
verified address.You can enable Cloudflare Turnstile as captcha. Not required but strongly recommended if you don't like spam.
BASE_URL
hostname (e.g. support.example.com) — not the sites that embed the
form.TURNSTILE_SITE_KEY and
TURNSTILE_SECRET env vars.The database runs in WAL mode, so copying the .db file directly can miss recent
writes. Either stop the container and copy /data, or take a snapshot:
docker exec freesupp sh -c 'ls /data' # freesupp.db, -wal, -shm
docker run --rm -v freesupp-data:/data -v "$PWD":/backup alpine \
tar czf /backup/freesupp-$(date +%F).tar.gz -C /data .
Restore is the reverse: put the file back at DB_PATH and start the container.
Schema migrations are embedded in the binary, tracked in a schema_migrations
table and applied at startup, so an older database is upgraded automatically.
One Go binary serves everything. go:embed bundles the widget script and both
Vite builds process:
widget.js — vanilla JS.Requirements: Go 1.26+ and Node 22+.
make deps # npm ci in both frontends
make build # vite build both apps, then go build (assets embedded)
make test # go test ./... plus both Vitest suites
make typecheck # vue-tsc --noEmit in both frontends
make run # local server on :8080 — mail logged, captcha skipped
make clean # remove the binary and both dist/ — run make build before committing
make run supplies dev values for the required variables and writes ./freesupp.db.
For frontend work with hot reload, make dev-visitor and make dev-inbox start Vite dev servers that proxy the API to :8080.
10 commits
Go
56.5%
TypeScript
28.2%
Vue
10.1%
JavaScript
2.9%
CSS
1.5%
FreeSupp is a minimal email based support system that provides:
That's it. Messages are delivered via email, operators log in with email + password.
It's very simple technically as well:
FreeSupp needs a public HTTP(S) URL: conversation links and the widget iframe are built from it. Operator session cookies are marked Secure whenever BASE_URL is https.
docker run -d --name freesupp \
-p 127.0.0.1:8080:8080 \
-v freesupp-data:/data \
-e BASE_URL="https://support.example.com" \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e SMTP_HOST="email-smtp.eu-central-1.amazonaws.com" \
-e SMTP_USER="..." \
-e SMTP_PASSWORD="..." \
-e MAIL_FROM="support@example.com" \
-e USE_PROXY="true" \
ghcr.io/kooler/freesupp:latest
Is is strongly recommended to not serve it via HTTP but rather put a reverse proxy (like Nginx or Caddy) in front of it. When using proxyu make sure to specify USE_PROXY="true" otherwise the rate limiting will use internal address and thus overlimit.
There is also sample docker-compose.yml for the reference.
Once all is up and running:
https://support.example.com/ (if you are running locally http://localhost:8080). Since no users exist yet, you will be requested to create the first account.http://localhost:9090/widget/).There are two types of users: normal and admins. Admins can manage users and appoint new admins, normal users can't. The rest is the same.
The first created user (requested after the installation) becomes the admin.
In regards to icoming messages -- everyone can read and answer any conversation.
Put this before </body> on any page that should show the bubble:
<script src="https://support.example.com/widget.js" defer></script>
Script injects a fixed-position bubble button that toggles the contact form.
Optional attributes on the <script> tag:
| Attribute | Default | Purpose |
|---|---|---|
data-label | Contact support | Accessible label / tooltip on the bubble |
data-color | #2563eb | Bubble background colour |
data-base-url | script's own origin | Override when the script is served from a CDN but the API lives elsewhere |
data-min-length | — | Shortest message the form accepts, in characters. Unset means the length is not checked |
data-trigger | — | CSS selector for your own button; see Your own button |
Both may also be set through window.freesuppSettings (see below); that object has higher prio wherever both script attributes and the object are specified.
<script src="https://support.example.com/widget.js"
data-label="Need help?" data-color="#111827" defer></script>
If you would rather not use the bubble, link visitors straight to https://support.example.com/widget/ — the form works as a standalone page.
Anything you put in window.freesuppSettings.metadata is attached to the message and shown beside the conversation in the Inbox. This is a good place for user information, browser they use, account info, etc.
<script>
window.freesuppSettings = {
minLength: 30,
metadata: {
Account: 'acme-inc',
Plan: 'pro',
Page: location.href,
Browser: navigator.userAgent,
},
};
</script>
<script src="https://support.example.com/widget.js" defer></script>
Values are sent as text: numbers and booleans are converted, nested objects, arrays and blank values are dropped. Keys longer than 64 characters are ignored, values longer than 500 characters are trimmed, and at most 30 entries are kept per message.
The sidebar is ordered alphabetically by key, not in the order you declare them.
Every message carries its own copy, taken when it was sent, so a thread that outlives a plan change or a switch of browser keeps both. The sidebar shows the most recent message that carried anything; a message with metadata is marked · N details in the thread, and clicking it shows what that one carried.
Set data-trigger (or window.freesuppSettings.trigger) a CSS selector to use a custom button instead of the default bubble.
<button id="help">Contact support</button>
<script src="https://support.example.com/widget.js" data-trigger="#help" defer></script>
The selector is matched at click time. Unlike minLength and metadata, the trigger must be set before the script runs as it decides whether to draw the bubble at all. Put window.freesuppSettings.trigger in a plain <script> block ahead of the widget tag, or use data-trigger.
Required:
| Variable | Description |
|---|---|
BASE_URL | Public URL of this deployment, e.g. https://support.example.com. Must start with http:// or https://; a trailing slash is trimmed. |
SESSION_SECRET | Random string used to sign operator session cookies. At least 16 characters; generate once with openssl rand -hex 32 and keep it secret — changing it signs everyone out. |
Optional:
| Variable | Default | Description |
|---|---|---|
LISTEN | :8080 | Listen address. |
DB_PATH | /data/freesupp.db | SQLite file. Put it on a volume. |
SMTP_HOST | — | Outbound mail server. When empty, no mail is sent — bodies are logged instead (dev mode). |
SMTP_PORT | 587 | 465 uses implicit TLS; any other port opportunistically upgrades via STARTTLS. |
SMTP_USER | — | SMTP username. PLAIN auth is used only when this is set. |
SMTP_PASSWORD | — | SMTP password. |
MAIL_FROM | — | Sender address. Required once SMTP_HOST is set. |
TURNSTILE_SITE_KEY | — | Cloudflare Turnstile site key. |
TURNSTILE_SECRET | — | Cloudflare Turnstile secret. Must be set together with the site key; leave both empty to disable the captcha. |
USE_PROXY | false | Take the visitor's IP from the last X-Forwarded-For entry — the one your proxy appended — instead of the socket peer. Enable it only when a reverse proxy is the sole route to this process — otherwise anyone can supply the header and mint a fresh rate-limit bucket per request. |
TZ | UTC | Standard container timezone; affects log timestamps only. Stored timestamps are always UTC. |
FreeSupp works with plain SMTP, so any provider supporting it would work.
For AWS SES you'd need to create SMTP credentials:
MAIL_FROM address.SMTP_HOST=email-smtp.<region>.amazonaws.com, SMTP_PORT=587,
SMTP_USER/SMTP_PASSWORD to those credentials, and MAIL_FROM to the
verified address.You can enable Cloudflare Turnstile as captcha. Not required but strongly recommended if you don't like spam.
BASE_URL
hostname (e.g. support.example.com) — not the sites that embed the
form.TURNSTILE_SITE_KEY and
TURNSTILE_SECRET env vars.The database runs in WAL mode, so copying the .db file directly can miss recent
writes. Either stop the container and copy /data, or take a snapshot:
docker exec freesupp sh -c 'ls /data' # freesupp.db, -wal, -shm
docker run --rm -v freesupp-data:/data -v "$PWD":/backup alpine \
tar czf /backup/freesupp-$(date +%F).tar.gz -C /data .
Restore is the reverse: put the file back at DB_PATH and start the container.
Schema migrations are embedded in the binary, tracked in a schema_migrations
table and applied at startup, so an older database is upgraded automatically.
One Go binary serves everything. go:embed bundles the widget script and both
Vite builds process:
widget.js — vanilla JS.Requirements: Go 1.26+ and Node 22+.
make deps # npm ci in both frontends
make build # vite build both apps, then go build (assets embedded)
make test # go test ./... plus both Vitest suites
make typecheck # vue-tsc --noEmit in both frontends
make run # local server on :8080 — mail logged, captcha skipped
make clean # remove the binary and both dist/ — run make build before committing
make run supplies dev values for the required variables and writes ./freesupp.db.
For frontend work with hot reload, make dev-visitor and make dev-inbox start Vite dev servers that proxy the API to :8080.
10 commits
Go
56.5%
TypeScript
28.2%
Vue
10.1%
JavaScript
2.9%
CSS
1.5%