No description
  • HTML 93%
  • Python 6.1%
  • Vim Snippet 0.9%
Find a file
bogac c2c301132a Initial commit: practice site and inquiry form endpoint
Single-page site for Dr. Ceylan Çelen (index.html, logo.svg) and a
standard-library Python endpoint for the inquiry form (inquiry.py),
with its systemd unit and Caddy snippet. Built and tested, not deployed.

inquiry.env holds SMTP credentials and is excluded via .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:09:51 +11:00
.gitignore Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
Caddyfile.snippet Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
handover.md Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
index.html Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
inquiry.py Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
inquiry.service Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
logo.svg Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00
README.md Initial commit: practice site and inquiry form endpoint 2026-10-24 01:09:51 +11:00

Practice site: build notes and deployment

Single-page website for Dr. Ceylan Çelen, Clinical Psychologist, plus a self-hosted endpoint for the inquiry form.

Files

File Goes where Purpose
index.html web host, site root The entire site. Self-contained apart from the favicon.
logo.svg web host, site root Favicon, and the vector master of the logo. Must sit beside index.html.
inquiry.py celen.cloud, /opt/inquiry/ The form endpoint.
inquiry.service celen.cloud, /etc/systemd/system/ systemd unit for the above.
inquiry.env celen.cloud, /etc/inquiry.env SMTP credentials. chmod 600.
Caddyfile.snippet stays on your Mac Text to paste into the server's Caddyfile.

The website and the form endpoint are independent. The site can live anywhere; celen.cloud only ever runs the endpoint.


What was built

The only surviving artwork was a 1400x1510 JPEG. It has been recovered as a clean vector, so you have a usable master again.

The method was color separation followed by tracing. Each pixel was classified to the nearest of the three logo colors, morphological cleanup removed JPEG ringing, potrace converted each region to Bezier paths, and the light blue background turned out to be geometrically a true circle (max radius 663.36px against an area-equivalent radius of 663.03px), so it was replaced with an actual <circle> element rather than a traced approximation. Result is 16 KB and scales to any size.

Palette, sampled from the original:

  • #87D9FF light blue disk
  • #0034CE deep blue Ç
  • #FFFFFF white C

The site

One HTML file, no build step, no dependencies beyond two Google Fonts. The logo paths are inlined as SVG <defs> and referenced twice with <use>, so the header mark and the hero mark share one copy of the geometry.

Design notes, in case you want to change something and wonder why it is the way it is:

  • The logo is two hands reaching toward each other and not quite touching, forming a C and a Ç. That gap is the organizing idea. Section dividers are two rules that approach and stop short of each other, and the one animation on the page is the white C and the blue Ç sliding in from opposite sides on load.
  • Type is Newsreader for display and Karla for body and UI. Both cover Turkish glyphs.
  • Colors come from the logo. There is no third accent hue on purpose.
  • All motion is inside @media (prefers-reduced-motion: no-preference).
  • Copy is verbatim from what you supplied, with one exception noted under Open decisions below.
  • JSON-LD structured data at the bottom of the file describes the practice as a Psychologist for search engines. Update the address there too if it ever changes.

The form endpoint

80 lines of standard library Python. No framework, no database, no pip install. It accepts one urlencoded POST, sends an email, and answers {"ok":true}.

Behavior, all of it tested:

Situation Response What the visitor sees
Valid submission 200 Thank you message
Honeypot field filled 200, discarded Thank you message
Missing name, email, or message 400 Inline validation notice
Body over 20 KB 413 Failure notice with email and phone
SMTP unreachable 502 Failure notice with email and phone

Two deliberate choices. log_message is overridden to a no-op so nothing a visitor types reaches journald, and the systemd unit runs under DynamicUser with ProtectSystem=strict, so a compromise yields a process that can open a socket and little else.


Deploying the site

  1. Choose a host and register the domain. Netlify or Cloudflare Pages, drag and drop, about ten minutes including DNS. Keep the site off celen.cloud so that an outage there cannot take down her referral path.
  2. Upload index.html and logo.svg together into the site root.

Deploying the form endpoint

Roughly 25 minutes plus DNS propagation. Replace you with your login name on the server, and HER-DOMAIN with the practice site's domain.

1. Copy the files across.

cd ~/Downloads/files
scp inquiry.py inquiry.service inquiry.env you@celen.cloud:/tmp/
ssh you@celen.cloud
sudo mkdir -p /opt/inquiry
sudo mv /tmp/inquiry.py /opt/inquiry/
sudo mv /tmp/inquiry.env /etc/inquiry.env
sudo mv /tmp/inquiry.service /etc/systemd/system/
sudo chmod 600 /etc/inquiry.env

2. Fill in the SMTP credentials.

sudo nano /etc/inquiry.env

Through Gmail that means SMTP_HOST=smtp.gmail.com, SMTP_PORT=465, SMTP_USER set to the sending address, and SMTP_PASS set to a 16 character app password from myaccount.google.com/apppasswords. A normal account password will fail login. INQUIRY_TO is where inquiries arrive and can differ from SMTP_USER.

3. Start the service.

sudo systemctl daemon-reload
sudo systemctl enable --now inquiry
systemctl status inquiry --no-pager

You want active (running).

4. Smoke test before involving DNS or TLS.

curl -i -X POST http://127.0.0.1:8099/ \
  --data-urlencode "name=Test" \
  --data-urlencode "email=you@example.com" \
  --data-urlencode "message=smoke test"

Expect 200, {"ok":true}, and an email that actually lands. On a 502 run journalctl -u inquiry -n 20, which prints the exception class name and no message content. Do not move on until mail is arriving. This step isolates SMTP from everything else.

5. Add the DNS record.

An A record for inquiry.celen.cloud pointing at the server. Confirm with dig +short inquiry.celen.cloud before continuing, because Caddy cannot issue a certificate until it resolves.

6. Add the Caddy block.

Paste Caddyfile.snippet into the server's Caddyfile with SITE_ORIGIN replaced by the practice site's exact origin. Scheme included, no trailing slash, no path, and it is her domain rather than inquiry.celen.cloud.

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Verify from your Mac:

curl -i -X POST https://inquiry.celen.cloud/ \
  -H "Origin: https://HER-DOMAIN" \
  --data-urlencode "name=Test" \
  --data-urlencode "email=you@example.com" \
  --data-urlencode "message=via caddy"

The response headers must include access-control-allow-origin.

7. Point the page at it.

Lines 500 and 501 of index.html:

const FORM_MODE = "endpoint";
const FORM_ENDPOINT = "https://inquiry.celen.cloud/";

Leave line 502 (INBOX) alone. That is the address shown to visitors when a submission fails, and it applies in every mode.

Redeploy, then submit once through the real form.


Configuration reference

FORM_MODE in index.html takes three values:

  • "endpoint" posts to FORM_ENDPOINT. Use this for inquiry.py, for an n8n webhook, or for a hosted service like Formspree or Basin. All three speak the same wire format because the page sends application/x-www-form-urlencoded.
  • "netlify" posts to / and relies on Netlify Forms. Works only if the site is hosted on Netlify. Currently the default.
  • "email" opens the visitor's mail client. Unreliable on phones and webmail. Use it only as a stopgap if you launch the site before the endpoint is up, because otherwise someone will type out a message and then be told it failed.

inquiry.py reads five environment variables from /etc/inquiry.env: INQUIRY_TO, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS. It also honors PORT, defaulting to 8099, which is the port the Caddy snippet proxies to.

Troubleshooting

Email arrives but the page says it failed. Missing or mismatched Access-Control-Allow-Origin. The POST succeeds; the browser refuses to let the JavaScript read the reply. Check SITE_ORIGIN in the Caddyfile character for character.

Everything fails when testing locally. A file:// page sends a null origin, which will never match. Test from the deployed site.

502 with SMTPAuthenticationError. Gmail with an account password instead of an app password.

502 with a name resolution error. The unit ships with AF_UNIX allowed in RestrictAddressFamilies precisely to prevent this. If you edited that line, put it back.

The form works but nothing arrives. Check the spam folder first, then confirm INQUIRY_TO is what you think it is.

Open decisions

  1. Hosting and domain. Nothing else can be tested end to end until this exists.
  2. privacy.html. The footer links to it and it does not exist yet. As a registered health service provider she is covered by the Australian Privacy Principles regardless of practice turnover, so this is not optional. What it has to say depends on item 4.
  3. The $158.95 out of pocket figure. Derived as $308 minus $149.05 and standard on Australian psychology sites, but it is mine rather than hers. Delete the third .fee block if she would rather not state it.
  4. The free-text clinical field. "What would you like help with?" invites people to type health information into a form. Self-hosting the endpoint keeps that off third party servers, but it still lands in a Gmail inbox, so the border crossing is relocated rather than removed. Two clean resolutions: disclose it in the privacy policy, or narrow the field to something like "How can I help, and when is a good time to reach you?" and hold the clinical conversation by phone. If data sovereignty is the real goal, moving the practice inbox off Gmail does more than any amount of self-hosted plumbing.