Paul NothaftandPaul Nothaft 1ef2b3c85b feat(events): publish without notifying, and send the gallery email later (#1235) (#1241)
* feat(events): publish without notifying, and send the gallery email later (#1235)

Publishing queued the gallery_created email whenever any customer email
existed, with no opt-out. A photographer working with a client who has no
address yet — the Instagram-team case in discussion #1086 — had to type their
OWN address into the required field, publish, receive the client-facing email
themselves, and hand the link over by DM. Turning off
`event_require_customer_email` is not the answer either: that is global, and
the same photographer usually does collect addresses.

Two halves, because a checkbox alone is only half a workflow:

- `notify_customer` on publish, default TRUE. Absent means notify, so the v1
  API, an older frontend and any script keep behaving exactly as before. When
  false the gallery goes live and nothing is queued — not the gallery_created
  email, not the assigned-customer-account notice, not WhatsApp. Publishing
  still logs activity and still fires the event.published webhook, because
  those describe a state change rather than a message to a customer.

- POST /:id/send-gallery-email for an already-published gallery. Deliberately
  not restricted to galleries published quietly: re-sending is a normal thing
  to want (spam folder, wrong address since corrected) and refusing would push
  people to unpublish and republish, changing gallery state to work around a
  mail problem. Refused for a draft, whose link would not work yet, and for an
  event with no recipient.

The email composition is now one helper shared by both, so an email sent a
week later is identical to one sent at publish.

UI: a checkbox in the publish dialog (checked by default, hidden when nobody
would be notified anyway), and a "Send gallery email" action on published
galleries that have a recipient. The password field follows the checkbox —
unchecking it means nothing is being sent, so there is no plaintext to carry
and no reason to demand it. EN + DE strings.

7 integration tests. Two fail without the change, verified by forcing
notifyCustomer true and re-running; the rest pin the default, the draft and
no-recipient refusals, and that a gallery with no recipient still publishes.

* fix(events): make the publish dialog description follow the checkbox (#1235)

Caught by screenshotting it. With "Send the gallery email now" unchecked, the
paragraph above still read "...and sends the notification email to
[email protected]" while the control directly beneath it said nothing would be
sent — the dialog contradicted itself at exactly the moment the admin is
deciding whether anything goes out.

It now reads "No email will be sent — you can send it later from this page."
when the box is clear. EN + DE.

* fix(events): close six gaps in publish-quietly found by external review (#1235)

TWO CORRECTIONS TO MY OWN VERIFICATION FIRST. `npx tsc --noEmit` in frontend/
is a NO-OP — the root tsconfig is solution-style with references and no
include, so it checks nothing. Every "tsc clean" I claimed on this branch came
from that. The real check, `tsc -p tsconfig.app.json`, showed two TS2339s I had
introduced: `event.host_email` does not exist on the frontend Event type, which
the admin API normalises away. Both recipient checks now use `customer_email`.

PASSWORD ON SEND-LATER. The action promised to send the link and password but
always called the endpoint without one, so a protected gallery got the
"(set at creation)" sentinel — unusable — and this is most needed right after a
quiet publish, the path that never collects a password. New
SendGalleryEmailDialog asks for it, same shape and reasoning as the publish
dialog (#627). Galleries with no password skip the field.

WHATSAPP-ONLY GALLERIES COULD NOT PUBLISH QUIETLY. willNotify ignored
customer_phone, so a phone-only gallery hid the opt-out AND told the admin
nothing would be sent — while publish queued the WhatsApp anyway. Phone now
counts, with its own description line.

ASSIGNED-ACCOUNT NOTICES COULD NOT BE SENT LATER. The dialog promised it; the
endpoint rejected anything without an inline recipient. It now falls through to
the same customer-account path publish uses.

EDITORS COULD NOT SEE THE ACTION. The send button was nested inside the
events.archive gate, so the default editor role — events.edit, no archive —
never saw a button for an endpoint it is allowed to call. Separate gates now.

DEAD LINKS. The endpoint only checked is_draft, so an archived, inactive or
expired gallery would send a link the gallery middleware rejects. All three are
refused with a reason.

9 backend tests (2 new), 22 across the event suites. eslint clean on every
changed frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): persist the send-later password, and fix a long-standing isGalleryPublic misuse (#1235)

Round 2 of external review.

THE EMAIL COULD CARRY A PASSWORD THE GALLERY REJECTS. The send-later dialog
invites "or pick a new one", but the route queued that plaintext without
touching password_hash — so the customer got credentials that do not open the
gallery. Worse than the sentinel it replaced, because it looks usable. The
route now hashes and persists first, exactly as publish does.

isGalleryPublic TAKES A VALUE, NOT AN EVENT — and this is pre-existing.
normalizeRequirePassword returns its default for anything that is not a
boolean/number/string, so isGalleryPublic(event) is ALWAYS false and
`requirePassword` was always true. The publish dialog on main has demanded a
password for public galleries for exactly this reason. Both call sites now
pass event.require_password. Fixing the older one alongside mine rather than
leaving a broken copy one line above a fixed one.

ASSIGNED-ACCOUNT GALLERIES HAD NO BUTTON. The route falls through to the
customer-account notice when there is no inline email, and the publish dialog
promises that notice can be sent later — but the button only appeared with a
customer_email, making the promise unkeepable.

WHATSAPP CLAIM SOFTENED. Publish only queues WhatsApp when the config exists
and is enabled, which the dialog cannot see. It now says the customer is
notified there "if WhatsApp is configured" rather than asserting a send.

10 backend tests (1 new, covering the rehash). eslint clean on every changed
frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): don't reset the password for an account-only notice, hide unusable actions (#1235)

Round 3 of external review. The first is a harm my own round-2 fix introduced.

PASSWORD RESET FOR NOTHING. Round 2 persisted the supplied password before
knowing which mail would go out. For a protected gallery with no inline email
but assigned accounts, the dialog still demands a password, the hash was
rewritten, and then the fallback sent customer_gallery_assigned — which links
to the customer portal and never mentions a password. Net effect: the live
gallery password silently changed and everyone holding the old one was locked
out, in exchange for nothing. It is now persisted only when the mail that
carries it is actually being sent.

BUTTONS THE BACKEND WOULD REFUSE. The send action rendered for expired and
inactive galleries, and counted assigned accounts the endpoint filters out as
inactive — walking the admin through a dialog to reach a generic error toast.
The card now mirrors the endpoint's eligibility rules, and only active accounts
count toward having a recipient.

11 backend tests (1 new, pinning that the hash is untouched on the account
path), 24 across the event suites. tsc and eslint clean on the changed files.

* fix(events): make the send-later action agree with what the endpoint will do

Three findings from an external review round, all the same shape: the UI
predicted the endpoint's behaviour and got it wrong.

GET /admin/events/:id mapped customer_accounts without is_active, so the
"only ACTIVE accounts count" filter in OverviewTab compared undefined and
excluded nothing. A gallery whose only assignments were deactivated showed
the send action, and the endpoint then filtered every recipient and
returned 400. is_active is exposed now, and the count applies the same
predicate the fallback uses — active AND holding an address.

is_active is coerced through toBoolean rather than compared with === false.
On the default SQLite backend it comes back as 0, and 0 === false is false,
so an inactive gallery kept offering a send that parseBooleanInput then
rejected. Same class as #1028.

The password prompt is gated on there being an inline recipient. With no
customer_email the backend takes the account fallback, which sends
customer_gallery_assigned — a portal link that never mentions a password —
and deliberately skips the rehash. Asking for one there blocked the send
behind a six-character value nothing consumes, and the dialog's promise
that it would be rehashed was false.

Frontend suite: 291 passed. tsc and eslint clean.

* fix(events): don't mail a portal link to a customer who cannot sign in

Round-2 finding from the external review.

A passive customer — created directly and never invited — is an active
account with a real address whose password_hash IS NULL. The account
fallback happily mailed it customer_gallery_assigned, which links to
/customer/dashboard, and customerAuth rejects login without a hash: the link
goes to a door that will not open. Worse than failing, the route counted it
and reported success, so the admin believed the customer had been told.

getAssignmentsForEvent now derives can_sign_in (the predicate, never the
hash) and the three call sites share one canReceiveGalleryNotice helper —
publish, send-later, and the payload the UI predicts from all have to agree
or the button appears and then 400s. The UI mirrors it.

Sending passive customers an invitation instead of skipping them is the
better product answer, and a separate feature. Refusing visibly beats a
silent non-delivery in the meantime.

Test asserts the refusal; it fails without the can_sign_in arm.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-09-01 08:18:00 +02:00

PicPeak Logo

📸 PicPeak

Open-source, self-hosted photo sharing for events.

License: MIT Docker Buy Me A Coffee

Homepage · Live Demo · Documentation · Support


PicPeak is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.

PicPeak Gallery Preview

Important

PicPeak has moved to its own GitHub organization. Docker images are now at ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml} (and on Docker Hub as picpeak/{backend,frontend,aio,ml}) and active development is on main. The old ghcr.io/the-luap/... path still responds but its tags are frozen at 2026-05-27 — if updates never arrive, check your image path first. See docs/migration-to-org.md for the one-line docker-compose.yml edit.

Contents

🎮 Live Demo

Try PicPeak without installing anything — demo.picpeak.app · admin panel

Email Password
[email protected] Demo2026!

The demo resets periodically. Uploaded content may be removed without notice.

🚀 Quick Start

Get PicPeak running in under 5 minutes:

# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
cd picpeak

# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env

# Start with Docker Compose
docker compose up -d

# Access at http://localhost:3000

On first start, open http://localhost:3000/admin and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in First-run setup.

Updating / release channels: set PICPEAK_CHANNEL (stable default, or beta) in .env, then docker compose pull && docker compose up -d. See RELEASING.md for the promotion cadence.

Or: one container, no compose file

For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:

docker run -d --name picpeak -p 3000:3000 \
  -v picpeak:/data \
  ghcr.io/picpeak/picpeak/aio:main

No environment variables to set — the JWT secret is generated on first start and kept on the volume.

Then open http://localhost:3000/admin and read the setup token with docker exec picpeak cat /data/db/SETUP_TOKEN, or open db/SETUP_TOKEN on the volume with any file manager if the host has no shell.

:main is the active-development tag, and today it is the only one the all-in-one image has — Dockerfile.aio landed after the current stable release, so :stable and :latest first appear for this image once the aio build reaches the stable branch. Switch to :stable then, or pin a published version tag if you would rather not track main.

The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a .picpeak backup and restore it into the full stack. See Single-container install for the volume layout, the external-Postgres variant, TLS, and the limits.

Docker images

GHCR Docker Hub
Backend ghcr.io/picpeak/picpeak/backend picpeak/backend
Frontend ghcr.io/picpeak/picpeak/frontend picpeak/frontend
All-in-one ghcr.io/picpeak/picpeak/aio picpeak/aio
ML sidecar (optional) ghcr.io/picpeak/picpeak/ml picpeak/ml

Both registries get the same digests and the same tags — stable/latest, a pinned x.y.z, and beta/main for the active development channel — for linux/amd64 and linux/arm64. Keep every image in one install on the same tag.

🌟 Why PicPeak?

Unlike expensive SaaS solutions, PicPeak gives you:

  • 💰 No Monthly Fees — one-time setup, unlimited galleries
  • 🔒 Complete Data Control — your photos stay on your server
  • 🎨 White-Label Ready — full branding customization
  • 📱 Mobile-First Design — beautiful on all devices
  • 🌍 Multi-Language — built-in i18n (EN, DE)

Features

For photographers — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a Live Slideshow projector view that auto-picks-up new uploads during live events.

For clients — clean mobile-optimized galleries, one-click bulk downloads, smart search, People in this gallery face grouping (opt-in per gallery, needs the optional ML sidecar), optional guest uploads, and download protection (watermarking + right-click prevention).

Technical — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible storage backends, webhooks, and security-first defaults (JWT, rate limiting, CORS).

🧾 For studios — CRM & Accounting (Beta, off by default)
  • 📝 Quotes → Contracts → Invoices — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
  • ⏱️ Hours Logging & Calendar — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
  • 🧾 Inbound Supplier Invoices & Expenses — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
  • 📊 Tax Report & Accountant Export — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
  • 🌍 VAT & Multi-currency — single VAT-code registry snapshotted onto each document

Warning

CRM & Accounting — examples only, verify locally. Feature-flagged off by default. Seeded contract blocks are written by the maintainer, not a lawyer; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are jurisdiction-specific guidance only. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read the CRM disclaimers first.

📖 Documentation

Full documentation lives at docs.picpeak.app — deployment, admin settings, API, branding, and more.

Topic Link
🚀 Deployment (Docker, env, reverse proxy, SSL) docs.picpeak.app/deployment
📦 Single-container install (one docker run, SQLite) docs.picpeak.app/deployment/single-container
⚙️ Admin settings reference docs.picpeak.app/guides/admin-settings
🎯 Creating events docs.picpeak.app/guides/creating-events
📽️ Live Slideshow docs.picpeak.app/features/live-slideshow
🙂 People in galleries (face grouping) docs.picpeak.app/features/face-recognition
💾 Backup & Restore docs.picpeak.app/guides/backup-restore
🔌 API reference docs.picpeak.app/api
🪝 Webhooks docs.picpeak.app/features/webhooks
💾 Storage backends (local / S3) docs.picpeak.app/features/storage-backends
💻 System requirements & tuning docs.picpeak.app/deployment/system-requirements
🧾 CRM & Accounting docs.picpeak.app/features/crm · disclaimers
🗺️ Roadmap GitHub Issues

Project meta: Contributing · License · Security · Code of Conduct

📊 Comparison with Alternatives

Feature PicPeak PicDrop Scrapbook.de Pixieset
Self-Hosted
Custom Branding Full Limited Limited (paid)
Monthly Cost $0* $29-199 €19-99 ~$60
Storage Limit Unlimited** 50-500GB 100-1000GB 3GBUnlimited***
Client Uploads Limited
API Access Paid
Open Source
Customer Accounts
Quotes / Contracts / Invoices 🧪 Beta
Incoming Invoices & Accounting 🧪 Beta

*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.

🏗️ Tech Stack

  • Backend: Node.js, Express, SQLite/PostgreSQL
  • Frontend: React, Tailwind CSS, Framer Motion
  • Storage: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see Storage Backends
  • Email: SMTP with customizable templates
  • Analytics: Privacy-focused with Umami integration
  • External media: point PicPeak at EXTERNAL_MEDIA_ROOT to reference existing originals read-only, index quickly, and generate thumbnails on demand

📸 Screenshots

Click to see the admin dashboard, analytics, and event management

🎛️ Admin Dashboard

PicPeak Admin Dashboard

📊 Analytics & Insights

PicPeak Analytics Dashboard

📁 Event Management

PicPeak Events Management

🤝 Contributing

We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the Contributing Guide to get started.

Found a security issue? Please open a security issue. See SECURITY.md for the policy.

Support the Project

PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also star the repo, share it, file good bug reports, or open a PR.

🙏 Acknowledgments

PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.

👥 Contributors

A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:

@the-luap — creator and lead maintainer

  • Gallery foundation (events, uploads, sharing, download protection, templates)
  • Backup & restore, analytics, branding/theming
  • The architecture every later feature builds on

@Luca-Timo

  • Native Apple Silicon multi-arch images
  • CRM & accounting suite (quotes/contracts/invoices)
  • Hours logging & Treuhänder/Banana tax export
  • Gallery header/banner decoupling

@Rekoo-PS — bug reports & product feedback

  • Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
  • Also a BuyMeACoffee supporter

If you've contributed and aren't listed here, please open a PR — this list is meant to grow.

📄 License

PicPeak is released under the MIT License. Use it freely for personal or commercial projects.


Made with ❤️ by photographers, for photographers
Homepage · Live Demo · Documentation · Support

S
Description
Secure photo sharing platform for weddings and events with automatic expiration and email notifications
Readme MIT
157 MiB
2025-07-24 15:24:20 +02:00
Languages
JavaScript 59.8%
TypeScript 38.5%
Shell 0.7%
Python 0.5%
CSS 0.4%
Other 0.1%