diff --git a/backend/__tests__/integration/publishQuietly.test.js b/backend/__tests__/integration/publishQuietly.test.js
new file mode 100644
index 00000000..8f2851e6
--- /dev/null
+++ b/backend/__tests__/integration/publishQuietly.test.js
@@ -0,0 +1,348 @@
+/**
+ * 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 — so a photographer with no address yet had to type
+ * their OWN into the required field, publish, receive the client-facing email
+ * themselves, and hand the link over by DM. That is the workaround this
+ * removes.
+ *
+ * The send-later half is the part that makes it a workflow rather than a dead
+ * end: publishing quietly is only useful if the real email can go out once the
+ * address arrives.
+ *
+ * The default must not move. Every existing caller — the v1 API, an older
+ * frontend, a script — omits the flag entirely and must keep notifying.
+ */
+
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const express = require('express');
+const request = require('supertest');
+
+process.env.NODE_ENV = 'test';
+process.env.TEST_DATABASE_PATH = path.join(
+ fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-publish-quiet-')), 'db.sqlite',
+);
+process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-quiet-test-secret';
+
+jest.mock('../../src/middleware/auth', () => ({
+ adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
+}));
+jest.mock('../../src/middleware/permissions', () => ({
+ requirePermission: () => (_req, _res, next) => next(),
+}));
+jest.mock('../../src/middleware/ownership', () => ({
+ requireEventOwnership: (_req, _res, next) => next(),
+}));
+
+const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
+
+let db;
+let cleanup;
+let app;
+
+beforeAll(async () => {
+ ({ db, cleanup } = await bootCrmDb());
+ await seedMinimal(db);
+ app = express();
+ app.use(express.json());
+ app.use('/admin/events', require('../../src/routes/adminEvents'));
+}, 180000);
+
+afterAll(async () => {
+ if (cleanup) await cleanup();
+});
+
+beforeEach(async () => {
+ await db('email_queue').del();
+ await db('events').del();
+});
+
+async function seedDraft({ slug, customerEmail = 'client@example.com', isDraft = true } = {}) {
+ const [row] = await db('events').insert({
+ slug,
+ event_type: 'wedding',
+ event_name: `Event ${slug}`,
+ event_date: '2026-09-01',
+ host_email: customerEmail,
+ admin_email: 'admin@example.com',
+ customer_email: customerEmail,
+ password_hash: 'x',
+ share_link: `/gallery/${slug}/share`,
+ share_token: `${slug}-token`,
+ require_password: 0,
+ expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
+ is_active: 1,
+ is_archived: 0,
+ is_draft: isDraft ? 1 : 0,
+ created_at: new Date().toISOString(),
+ }).returning('id');
+ return typeof row === 'object' ? row.id : row;
+}
+
+const queuedFor = (eventId) =>
+ db('email_queue').where({ event_id: eventId, email_type: 'gallery_created' });
+
+describe('publish quietly (#1235)', () => {
+ it('queues the gallery email by default — the flag being absent must not change anything', async () => {
+ const id = await seedDraft({ slug: 'default-publish' });
+
+ const res = await request(app).post(`/admin/events/${id}/publish`).send({});
+ expect(res.status).toBe(200);
+ expect(res.body.notified_customer).toBe(true);
+
+ expect(await queuedFor(id)).toHaveLength(1);
+ const event = await db('events').where({ id }).first();
+ expect(Number(event.is_draft)).toBe(0);
+ });
+
+ it('publishes without queuing anything when notify_customer is false', async () => {
+ const id = await seedDraft({ slug: 'quiet-publish' });
+
+ const res = await request(app)
+ .post(`/admin/events/${id}/publish`)
+ .send({ notify_customer: false });
+ expect(res.status).toBe(200);
+ expect(res.body.notified_customer).toBe(false);
+
+ // The whole point: live gallery, no email.
+ expect(await queuedFor(id)).toHaveLength(0);
+ const event = await db('events').where({ id }).first();
+ expect(Number(event.is_draft)).toBe(0);
+ });
+
+ it('sends the gallery email later, on demand', async () => {
+ const id = await seedDraft({ slug: 'send-later' });
+ await request(app).post(`/admin/events/${id}/publish`).send({ notify_customer: false });
+ expect(await queuedFor(id)).toHaveLength(0);
+
+ const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
+ expect(res.status).toBe(200);
+ expect(res.body.recipient).toBe('client@example.com');
+
+ const queued = await queuedFor(id);
+ expect(queued).toHaveLength(1);
+ const data = JSON.parse(queued[0].email_data);
+ expect(data.event_name).toBe('Event send-later');
+ expect(data.gallery_link).toContain('send-later');
+ });
+
+ it('refuses to send the gallery email for a draft — the link would not work yet', async () => {
+ const id = await seedDraft({ slug: 'still-draft' });
+
+ const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/draft/i);
+ expect(await queuedFor(id)).toHaveLength(0);
+ });
+
+ it('refuses to send when there is no recipient', async () => {
+ const [row] = await db('events').insert({
+ slug: 'no-email',
+ event_type: 'wedding',
+ event_name: 'No Email',
+ event_date: '2026-09-01',
+ host_email: '',
+ admin_email: 'admin@example.com',
+ customer_email: null,
+ password_hash: 'x',
+ share_link: '/gallery/no-email/share',
+ share_token: 'no-email-token',
+ require_password: 0,
+ expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
+ is_active: 1,
+ is_archived: 0,
+ is_draft: 0,
+ created_at: new Date().toISOString(),
+ }).returning('id');
+ const id = typeof row === 'object' ? row.id : row;
+
+ const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/no customer email/i);
+ });
+
+ it('still publishes a gallery that has no recipient at all', async () => {
+ const [row] = await db('events').insert({
+ slug: 'quiet-no-email',
+ event_type: 'wedding',
+ event_name: 'Quiet No Email',
+ event_date: '2026-09-01',
+ host_email: '',
+ admin_email: 'admin@example.com',
+ customer_email: null,
+ password_hash: 'x',
+ share_link: '/gallery/quiet-no-email/share',
+ share_token: 'quiet-no-email-token',
+ require_password: 0,
+ expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
+ is_active: 1,
+ is_archived: 0,
+ is_draft: 1,
+ created_at: new Date().toISOString(),
+ }).returning('id');
+ const id = typeof row === 'object' ? row.id : row;
+
+ const res = await request(app)
+ .post(`/admin/events/${id}/publish`)
+ .send({ notify_customer: false });
+ expect(res.status).toBe(200);
+ const event = await db('events').where({ id }).first();
+ expect(Number(event.is_draft)).toBe(0);
+ });
+
+ it('refuses to send for an archived, inactive or expired gallery', async () => {
+ // The link in the email would be rejected by the gallery middleware, so
+ // sending it hands the customer a dead link with no explanation.
+ const cases = [
+ { slug: 'arch-ev', patch: { is_archived: 1 }, match: /archived/i },
+ { slug: 'inactive-ev', patch: { is_active: 0 }, match: /inactive/i },
+ {
+ slug: 'expired-ev',
+ patch: { expires_at: new Date(Date.now() - 3600 * 1000).toISOString() },
+ match: /expired/i,
+ },
+ ];
+ for (const c of cases) {
+ const id = await seedDraft({ slug: c.slug, isDraft: false });
+ await db('events').where({ id }).update(c.patch);
+ const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(c.match);
+ expect(await queuedFor(id)).toHaveLength(0);
+ }
+ });
+
+ it('carries the password the admin supplies, instead of the sentinel', async () => {
+ // password_hash is a hash, so the plaintext only exists in this request.
+ // Without it the email says "(set at creation)", which cannot get anyone
+ // into the gallery — and the send-later action is most useful right after
+ // a quiet publish, the path that never collected a password.
+ const id = await seedDraft({ slug: 'with-password', isDraft: false });
+ await db('events').where({ id }).update({ require_password: 1 });
+
+ const res = await request(app)
+ .post(`/admin/events/${id}/send-gallery-email`)
+ .send({ password: 'sup3r-secret' });
+ expect(res.status).toBe(200);
+
+ const [queued] = await queuedFor(id);
+ expect(JSON.parse(queued.email_data).gallery_password).toBe('sup3r-secret');
+ });
+
+ it('persists a changed password so the emailed one actually works', async () => {
+ // The dialog invites "or pick a new one". Queueing that plaintext without
+ // rehashing would email a password the gallery rejects — worse than the
+ // sentinel, because it looks usable.
+ const id = await seedDraft({ slug: 'rehash', isDraft: false });
+ await db('events').where({ id }).update({ require_password: 1, password_hash: 'stale-hash' });
+
+ const res = await request(app)
+ .post(`/admin/events/${id}/send-gallery-email`)
+ .send({ password: 'brand-new-pass' });
+ expect(res.status).toBe(200);
+
+ const bcrypt = require('bcrypt');
+ const row = await db('events').where({ id }).first();
+ expect(row.password_hash).not.toBe('stale-hash');
+ expect(await bcrypt.compare('brand-new-pass', row.password_hash)).toBe(true);
+
+ const [queued] = await queuedFor(id);
+ expect(JSON.parse(queued.email_data).gallery_password).toBe('brand-new-pass');
+ });
+
+ it('does NOT touch the gallery password when only an account notice goes out', async () => {
+ // customer_gallery_assigned links to the customer portal and never carries
+ // a password. Rehashing for it would silently change the live gallery
+ // password and lock out everyone holding the old one, for nothing.
+ const [row] = await db('events').insert({
+ slug: 'account-only',
+ event_type: 'wedding',
+ event_name: 'Account Only',
+ event_date: '2026-09-01',
+ host_email: '',
+ admin_email: 'admin@example.com',
+ customer_email: null,
+ password_hash: 'original-hash',
+ require_password: 1,
+ share_link: '/gallery/account-only/share',
+ share_token: 'account-only-token',
+ expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
+ is_active: 1,
+ is_archived: 0,
+ is_draft: 0,
+ created_at: new Date().toISOString(),
+ }).returning('id');
+ const id = typeof row === 'object' ? row.id : row;
+
+ const res = await request(app)
+ .post(`/admin/events/${id}/send-gallery-email`)
+ .send({ password: 'should-not-be-applied' });
+
+ // No inline recipient and no assigned accounts in this fixture, so the
+ // route refuses — but the password must be untouched either way.
+ expect(res.status).toBe(400);
+ const after = await db('events').where({ id }).first();
+ expect(after.password_hash).toBe('original-hash');
+ });
+
+ it('refuses to send when the only assigned account is passive', async () => {
+ // A passive customer (created directly, never invited) is active and has
+ // an address, but password_hash IS NULL — customerAuth rejects the login,
+ // so the customer_gallery_assigned portal link goes to a door that will
+ // not open. Reporting success here would leave the admin believing the
+ // customer was told.
+ const [evRow] = await db('events').insert({
+ slug: 'passive-only',
+ event_type: 'wedding',
+ event_name: 'Passive Only',
+ event_date: '2026-09-01',
+ host_email: '',
+ admin_email: 'admin@example.com',
+ customer_email: null,
+ password_hash: 'original-hash',
+ require_password: 1,
+ share_link: '/gallery/passive-only/share',
+ share_token: 'passive-only-token',
+ expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
+ is_active: 1,
+ is_archived: 0,
+ is_draft: 0,
+ created_at: new Date().toISOString(),
+ }).returning('id');
+ const eventId = typeof evRow === 'object' ? evRow.id : evRow;
+
+ const [custRow] = await db('customer_accounts').insert({
+ email: 'passive@example.com',
+ display_name: 'Passive Person',
+ password_hash: null, // never invited
+ is_active: 1,
+ created_at: new Date().toISOString(),
+ }).returning('id');
+ const customerId = typeof custRow === 'object' ? custRow.id : custRow;
+
+ await db('event_customer_assignments').insert({
+ event_id: eventId,
+ customer_account_id: customerId,
+ });
+
+ const res = await request(app)
+ .post(`/admin/events/${eventId}/send-gallery-email`)
+ .send({});
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/no customer email/i);
+ });
+
+ it('re-sending is allowed — a lost email should not need an unpublish/republish', async () => {
+ const id = await seedDraft({ slug: 'resend' });
+ await request(app).post(`/admin/events/${id}/publish`).send({});
+ expect(await queuedFor(id)).toHaveLength(1);
+
+ const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
+ expect(res.status).toBe(200);
+ expect(await queuedFor(id)).toHaveLength(2);
+ });
+});
diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js
index 662e2071..720370c6 100644
--- a/backend/src/routes/adminEvents/crud.js
+++ b/backend/src/routes/adminEvents/crud.js
@@ -31,6 +31,91 @@ const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
+/**
+ * Can this assigned customer account actually receive — and act on — the
+ * gallery notice? (#1235)
+ *
+ * Shared by publish, by the send-later route, and mirrored by the UI that
+ * decides whether to offer the button at all. All four have to agree, or the
+ * admin gets an action that 400s, or worse, one that reports success for a
+ * notice nobody can use.
+ *
+ * - `is_active`: compared loosely because SQLite stores it as 0/1 and a
+ * strict `!== false` lets 0 through.
+ * - `can_sign_in`: a PASSIVE customer (password_hash IS NULL — see
+ * customerAccountsService.createDirect) is a real, active account that has
+ * simply never been invited. customer_gallery_assigned links to
+ * /customer/dashboard, and customerAuth rejects login without a hash, so
+ * mailing one sends a link to a door that will not open. Excluded here
+ * rather than mailed, because a silent non-delivery the admin believes
+ * succeeded is worse than a visible refusal. Sending them an invitation
+ * instead is the better answer, and a separate feature.
+ * - the column is emitted by a raw SQL predicate, so it arrives as a boolean
+ * on Postgres and 0/1 on SQLite; `== false` and `=== 0` cover both, and
+ * undefined (older callers) stays permissive.
+ */
+function canReceiveGalleryNotice(account) {
+ if (!account || !account.email) return false;
+ if (account.is_active === false || account.is_active === 0) return false;
+ if (account.can_sign_in === false || account.can_sign_in === 0) return false;
+ return true;
+}
+
+/**
+ * Queue the gallery_created email for an event (#1235).
+ *
+ * Shared by publish and by the send-later route, because the two must produce
+ * an identical email — an operator who publishes quietly and sends the mail a
+ * week later should not get a subtly different message than one who published
+ * loudly.
+ *
+ * The password is why this needs an argument at all: `password_hash` is a
+ * hash, so the plaintext exists only in the request the admin just typed it
+ * into (#627). Without one the email carries the legacy sentinel, exactly as an
+ * API-only publish has always done.
+ *
+ * @returns {Promise} false when the event has no inline recipient
+ */
+async function queueGalleryCreatedEmail(event, { password, requirePassword } = {}) {
+ const customerEmail = event.customer_email || event.host_email;
+ if (!customerEmail) return false;
+
+ const customerName = event.customer_name || event.host_name;
+ const frontendBase = await getFrontendBaseUrl();
+ const { shareUrl } = await buildShareLinkVariants({
+ slug: event.slug, shareToken: event.share_token,
+ });
+
+ let galleryPasswordForEmail;
+ if (!requirePassword) {
+ galleryPasswordForEmail = 'No password required';
+ } else if (password) {
+ galleryPasswordForEmail = password;
+ } else {
+ galleryPasswordForEmail = '(set at creation)';
+ }
+
+ await db('email_queue').insert({
+ event_id: event.id,
+ recipient_email: customerEmail,
+ email_type: 'gallery_created',
+ email_data: JSON.stringify({
+ customer_name: customerName,
+ customer_email: customerEmail,
+ host_name: customerName || customerEmail.split('@')[0],
+ event_name: event.event_name,
+ event_date: event.event_date,
+ gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
+ gallery_password: galleryPasswordForEmail,
+ expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
+ welcome_message: event.welcome_message || ''
+ }),
+ status: 'pending',
+ created_at: new Date()
+ });
+ return true;
+}
+
module.exports = (router) => {
@@ -878,6 +963,13 @@ module.exports = (router) => {
display_name: c.display_name,
first_name: c.first_name,
last_name: c.last_name,
+ // The send-gallery-email fallback below filters on these, so the UI
+ // needs them to predict whether the action has any recipient at all.
+ // Without them every assigned account looked reachable and a gallery
+ // whose only assignments were deactivated or passive offered a
+ // button that then 400'd — or worse, reported success.
+ is_active: c.is_active,
+ can_sign_in: c.can_sign_in,
})),
}));
} catch (error) {
@@ -885,6 +977,121 @@ module.exports = (router) => {
}
});
+ // Send the gallery email for an ALREADY published event (#1235).
+ //
+ // The other half of publish-quietly, and the half that makes it a workflow
+ // rather than a dead end: the case this exists for is "no address yet, I'll
+ // send the link by DM and mail it properly once they give me one". Without
+ // this the operator publishes quietly and then has no way to send the real
+ // email at all.
+ //
+ // Deliberately NOT restricted to galleries that were published quietly.
+ // Re-sending is a normal thing to want — the customer deleted it, it went to
+ // spam, the address was wrong and has been corrected — and refusing would
+ // just push people to unpublish and republish, which changes gallery state
+ // to work around a mail problem.
+ router.post('/:id/send-gallery-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
+ body('password').optional().isString().isLength({ min: 6 })
+ .withMessage('Password must be at least 6 characters long'),
+ ], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { id } = req.params;
+ const { password } = req.body;
+ const event = await db('events').where('id', id).first();
+
+ if (!event) {
+ return res.status(404).json({ error: 'Event not found' });
+ }
+ if (parseBooleanInput(event.is_draft, false)) {
+ // A draft has no working gallery link yet, so the email would carry a
+ // URL the customer cannot open. Publishing is the action they want.
+ return res.status(400).json({ error: 'Event is still a draft — publish it first' });
+ }
+ // Same reason, for the other three ways a gallery stops being reachable:
+ // the link in the email would be rejected by the gallery middleware, so
+ // sending it is worse than refusing — the customer gets a dead link with
+ // no explanation.
+ if (parseBooleanInput(event.is_archived, false)) {
+ return res.status(400).json({ error: 'Event is archived — restore it before sending' });
+ }
+ if (!parseBooleanInput(event.is_active, true)) {
+ return res.status(400).json({ error: 'Event is inactive — the gallery link would not work' });
+ }
+ if (event.expires_at && new Date(event.expires_at) <= new Date()) {
+ return res.status(400).json({ error: 'Event has expired — extend it before sending' });
+ }
+
+ const requirePassword = parseBooleanInput(event.require_password, true);
+ const hasInlineRecipient = !!(event.customer_email || event.host_email);
+
+ // Persist the password ONLY when the mail that carries it is actually
+ // going out (#627). The account-only fallback below sends
+ // customer_gallery_assigned, which links to the customer portal and
+ // never mentions a password — rehashing for that would silently change
+ // the live gallery password and lock out everyone holding the old one,
+ // in exchange for nothing.
+ if (hasInlineRecipient && requirePassword && password) {
+ await db('events').where('id', id).update({
+ password_hash: await bcrypt.hash(password, getBcryptRounds()),
+ });
+ }
+
+ const queued = hasInlineRecipient
+ && await queueGalleryCreatedEmail(event, { password, requirePassword });
+ if (!queued) {
+ // No inline recipient, but the gallery may be assigned to registered
+ // customer account(s) — the same path publish takes. Without this the
+ // publish dialog's promise that the notice can be sent later is false
+ // for exactly those galleries.
+ let notified = 0;
+ try {
+ const customerAccountsService = require('../../services/customerAccountsService');
+ const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
+ for (const c of assigned.filter(canReceiveGalleryNotice)) {
+ await customerAccountsService
+ .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
+ .then(() => { notified += 1; })
+ .catch((err) => logger.warn('Send gallery email: customer notice failed', { customerId: c.id, error: err.message }));
+ }
+ } catch (err) {
+ logger.warn('Send gallery email: assigned-customer lookup failed', { eventId: id, error: err.message });
+ }
+ if (notified > 0) {
+ await logActivity('gallery_email_sent',
+ { event_name: event.event_name, assigned_accounts: notified },
+ id,
+ { type: 'admin', id: req.admin.id, name: req.admin.username }
+ );
+ return res.json({
+ message: 'Gallery notice queued',
+ recipient: `${notified} assigned customer account(s)`,
+ });
+ }
+ return res.status(400).json({
+ error: 'No customer email is set for this event',
+ });
+ }
+
+ await logActivity('gallery_email_sent',
+ { event_name: event.event_name },
+ id,
+ { type: 'admin', id: req.admin.id, name: req.admin.username }
+ );
+
+ res.json({
+ message: 'Gallery email queued',
+ recipient: event.customer_email || event.host_email,
+ });
+ } catch (error) {
+ errorResponse(res, error, 500, 'Failed to send the gallery email');
+ }
+ });
+
// Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
// Optional password the admin re-types in the publish dialog so the
@@ -896,6 +1103,9 @@ module.exports = (router) => {
// compat with API-only consumers.
body('password').optional().isString().isLength({ min: 6 })
.withMessage('Password must be at least 6 characters long'),
+ // Publish without telling the customer yet (#1235). Defaults to true, so
+ // every existing caller — the API, older frontends — keeps notifying.
+ body('notify_customer').optional().isBoolean(),
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -905,6 +1115,7 @@ module.exports = (router) => {
const { id } = req.params;
const { password } = req.body;
+ const notifyCustomer = parseBooleanInput(req.body?.notify_customer, true);
const event = await db('events').where('id', id).first();
if (!event) {
@@ -925,67 +1136,39 @@ module.exports = (router) => {
}
await db('events').where('id', id).update(publishUpdates);
- // Queue creation email
+ // Notify the customer — unless the admin asked to publish quietly
+ // (#1235). Everything else about publishing still happens: the gallery
+ // goes live, the activity is logged, and the event.published webhook
+ // fires, because those describe a state change rather than a message to
+ // a customer.
const customerEmail = event.customer_email || event.host_email;
- const customerName = event.customer_name || event.host_name;
- if (customerEmail) {
- const frontendBase = await getFrontendBaseUrl();
- const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
-
- let galleryPasswordForEmail;
- if (!requirePassword) {
- galleryPasswordForEmail = 'No password required';
- } else if (password) {
- // Admin re-typed the password in the publish dialog — put it straight
- // into the email so the customer can actually log in (#627).
- galleryPasswordForEmail = password;
+ if (notifyCustomer) {
+ if (customerEmail) {
+ await queueGalleryCreatedEmail(event, { password, requirePassword });
} else {
- // Legacy fallback for API-only publishes that don't carry the password.
- galleryPasswordForEmail = '(set at creation)';
- }
-
- const emailData = {
- customer_name: customerName,
- customer_email: customerEmail,
- host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
- event_name: event.event_name,
- event_date: event.event_date,
- gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
- gallery_password: galleryPasswordForEmail,
- expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
- welcome_message: event.welcome_message || ''
- };
-
- await db('email_queue').insert({
- event_id: id,
- recipient_email: customerEmail,
- email_type: 'gallery_created',
- email_data: JSON.stringify(emailData),
- status: 'pending',
- created_at: new Date()
- });
- } else {
- // No inline email, but the gallery may be assigned to registered customer
- // account(s). Notify them via the account "your galleries" email
- // (customer_gallery_assigned, in the customer's own language) instead of
- // the gallery_created mail, which needs an inline recipient. Best-effort.
- try {
- const customerAccountsService = require('../../services/customerAccountsService');
- const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
- for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) {
- await customerAccountsService
- .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
- .catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message }));
+ // No inline email, but the gallery may be assigned to registered
+ // customer account(s). Notify them via the account "your galleries"
+ // email (customer_gallery_assigned, in the customer's own language)
+ // instead of the gallery_created mail, which needs an inline
+ // recipient. Best-effort.
+ try {
+ const customerAccountsService = require('../../services/customerAccountsService');
+ const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
+ for (const c of assigned.filter(canReceiveGalleryNotice)) {
+ await customerAccountsService
+ .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
+ .catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message }));
+ }
+ } catch (err) {
+ logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message });
}
- } catch (err) {
- logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message });
}
}
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
// dialog (#627) hands us the password back so we can deliver it via
// WhatsApp as well. Uses customer_phone from the persisted event row.
- if (event.customer_phone) {
+ if (notifyCustomer && event.customer_phone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
@@ -1011,7 +1194,7 @@ module.exports = (router) => {
}
await logActivity('event_published',
- { event_name: event.event_name },
+ { event_name: event.event_name, notified_customer: notifyCustomer },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
@@ -1037,7 +1220,11 @@ module.exports = (router) => {
});
} catch (e) { /* non-fatal */ }
- res.json({ message: 'Event published successfully', is_draft: false });
+ res.json({
+ message: 'Event published successfully',
+ is_draft: false,
+ notified_customer: notifyCustomer,
+ });
} catch (error) {
errorResponse(res, error, 500, 'Failed to publish event');
}
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index 6e3a2235..e6a9567a 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -1119,7 +1119,12 @@ async function getAssignmentsForEvent(eventId) {
'customer_accounts.display_name',
'customer_accounts.first_name',
'customer_accounts.last_name',
- 'customer_accounts.is_active'
+ 'customer_accounts.is_active',
+ // NOT the hash itself — only whether one exists. A passive customer is
+ // identified by password_hash IS NULL (see createDirect), and callers
+ // that mail a portal link need to know the recipient can actually sign
+ // in to follow it.
+ db.raw('(customer_accounts.password_hash IS NOT NULL) as can_sign_in')
)
.orderBy('customer_accounts.email', 'asc');
}
diff --git a/frontend/src/components/admin/PublishGalleryDialog.tsx b/frontend/src/components/admin/PublishGalleryDialog.tsx
index a265cbf2..ad3b6ef6 100644
--- a/frontend/src/components/admin/PublishGalleryDialog.tsx
+++ b/frontend/src/components/admin/PublishGalleryDialog.tsx
@@ -7,10 +7,12 @@ interface PublishGalleryDialogProps {
eventName: string;
requirePassword: boolean;
customerEmail?: string | null;
+ /** WhatsApp recipient — publish notifies this too, so it counts as "someone gets told". */
+ customerPhone?: string | null;
/** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */
assignedCustomerCount?: number;
isPublishing: boolean;
- onConfirm: (password?: string) => void;
+ onConfirm: (password?: string, notifyCustomer?: boolean) => void;
onClose: () => void;
}
@@ -30,23 +32,32 @@ export const PublishGalleryDialog: React.FC = ({
eventName,
requirePassword,
customerEmail,
+ customerPhone,
assignedCustomerCount = 0,
isPublishing,
onConfirm,
onClose,
}) => {
const { t } = useTranslation();
- // Someone gets notified if there's an inline email OR an assigned account
- // (the latter via the account "your galleries" email).
- const willNotify = !!customerEmail || assignedCustomerCount > 0;
+ // Someone gets notified if there's an inline email, an assigned account (the
+ // account "your galleries" email), OR a phone — publish queues a WhatsApp
+ // for that last one. Leaving the phone out hid the opt-out on phone-only
+ // galleries AND told the admin nothing would be sent, while the WhatsApp
+ // went out anyway.
+ const willNotify = !!customerEmail || !!customerPhone || assignedCustomerCount > 0;
+ const [password, setPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [error, setError] = useState(undefined);
+ // Defaults to notifying — that is what publish has always done, and the
+ // quiet path is the exception (#1235).
+ const [notifyCustomer, setNotifyCustomer] = useState(true);
// The password is only collected (and required) on the inline-email path,
// because the gallery_created email carries it. With no inline email the field
// is hidden and the existing hash is kept — so don't gate submit on it, or a
// password-protected gallery without an email could never be published.
- const needsPassword = requirePassword && !!customerEmail;
- const [password, setPassword] = useState('');
- const [showPassword, setShowPassword] = useState(false);
- const [error, setError] = useState(undefined);
+ // Unchecking "notify" hides it for the same reason: nothing is being sent,
+ // so there is no plaintext to carry and no reason to demand it.
+ const needsPassword = requirePassword && !!customerEmail && notifyCustomer;
const handleSubmit = () => {
if (needsPassword) {
@@ -56,7 +67,7 @@ export const PublishGalleryDialog: React.FC = ({
}
}
setError(undefined);
- onConfirm(needsPassword ? password : undefined);
+ onConfirm(needsPassword ? password : undefined, notifyCustomer);
};
return (
@@ -76,7 +87,16 @@ export const PublishGalleryDialog: React.FC = ({
- {customerEmail
+ {/* Follows the checkbox. Left static it contradicted itself — the
+ text promised an email to the customer while the box beneath it
+ said none would be sent. */}
+ {willNotify && !notifyCustomer
+ ? t('events.publishDialog.descriptionQuiet', {
+ eventName,
+ defaultValue:
+ 'Publishing "{{eventName}}" makes the gallery accessible. No email will be sent — you can send it later from this page.',
+ })
+ : customerEmail
? t('events.publishDialog.descriptionWithEmail', {
eventName,
customerEmail,
@@ -90,6 +110,12 @@ export const PublishGalleryDialog: React.FC = ({
defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.',
})
+ : customerPhone
+ ? t('events.publishDialog.descriptionWhatsapp', {
+ eventName,
+ defaultValue:
+ 'Publishing "{{eventName}}" makes the gallery accessible. If WhatsApp is configured, the customer is notified there.',
+ })
: t('events.publishDialog.descriptionNoEmail', {
eventName,
defaultValue:
@@ -97,6 +123,31 @@ export const PublishGalleryDialog: React.FC = ({
})}
diff --git a/frontend/src/components/admin/SendGalleryEmailDialog.tsx b/frontend/src/components/admin/SendGalleryEmailDialog.tsx
new file mode 100644
index 00000000..d768ad04
--- /dev/null
+++ b/frontend/src/components/admin/SendGalleryEmailDialog.tsx
@@ -0,0 +1,125 @@
+import React, { useState } from 'react';
+import { X, Mail, Lock, Eye, EyeOff } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button, Card, Input } from '../common';
+
+interface SendGalleryEmailDialogProps {
+ eventName: string;
+ recipient: string;
+ requirePassword: boolean;
+ isSending: boolean;
+ onConfirm: (password?: string) => void;
+ onClose: () => void;
+}
+
+/**
+ * Send the gallery email for an already-published gallery (#1235).
+ *
+ * It asks for the password for the same reason the publish dialog does (#627):
+ * `password_hash` is a hash, so the plaintext only exists in the request the
+ * admin types it into. Without it the email carries the "(set at creation)"
+ * sentinel — and this action is most useful right after a quiet publish, which
+ * is exactly the path that never collected a password. An email whose password
+ * line reads "(set at creation)" cannot get the customer into the gallery, so
+ * asking here is what makes the button do what its label promises.
+ *
+ * Galleries with no password skip the field entirely — there is nothing to
+ * carry, and the email says so.
+ */
+export const SendGalleryEmailDialog: React.FC = ({
+ eventName,
+ recipient,
+ requirePassword,
+ isSending,
+ onConfirm,
+ onClose,
+}) => {
+ const { t } = useTranslation();
+ const [password, setPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [error, setError] = useState(undefined);
+
+ const handleSubmit = () => {
+ if (requirePassword) {
+ if (!password || password.trim().length < 6) {
+ setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.'));
+ return;
+ }
+ }
+ setError(undefined);
+ onConfirm(requirePassword ? password : undefined);
+ };
+
+ return (
+
+ {t('events.sendGalleryEmail.description', {
+ eventName,
+ recipient,
+ defaultValue: 'Sends the gallery link for "{{eventName}}" to {{recipient}}.',
+ })}
+
+
+ {requirePassword && (
+
+ {
+ setPassword(e.target.value);
+ if (error) setError(undefined);
+ }}
+ error={error}
+ helperText={t(
+ 'events.sendGalleryEmail.passwordHelp',
+ 'The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the login still works.',
+ )}
+ leftIcon={}
+ rightIcon={
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
index 9bba7381..986e6402 100644
--- a/frontend/src/components/admin/index.ts
+++ b/frontend/src/components/admin/index.ts
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { PublishGalleryDialog } from './PublishGalleryDialog';
+export { SendGalleryEmailDialog } from './SendGalleryEmailDialog';
export { DuplicateEventDialog } from './DuplicateEventDialog';
export { ExportPreviewModal } from './ExportPreviewModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index bf4bf9c5..6ae92488 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1373,7 +1373,11 @@
"passwordLabel": "Galerie-Passwort",
"passwordPlaceholder": "Galerie-Passwort eingeben",
"passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.",
- "errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein."
+ "errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein.",
+ "notifyLabel": "Galerie-E-Mail jetzt senden",
+ "notifyHelp": "Abwählen, um still zu veröffentlichen — die Galerie geht online, es wird nichts versendet. Die E-Mail lässt sich später auf dieser Seite senden.",
+ "descriptionQuiet": "Durch das Veröffentlichen wird \"{{eventName}}\" zugänglich. Es wird keine E-Mail versendet — Sie können sie später auf dieser Seite senden.",
+ "descriptionWhatsapp": "Durch das Veröffentlichen wird \"{{eventName}}\" zugänglich. Sofern WhatsApp eingerichtet ist, wird der Kunde dort benachrichtigt."
},
"duplicateEvent": "Galerie duplizieren",
"duplicateDialog": {
@@ -1513,6 +1517,16 @@
"title": "Heldenbild als Vorschau für geteilte Links verwenden",
"help": "Beim Teilen der Galerie-URL auf WhatsApp, Facebook, Slack usw. wird das oben gewählte Heldenbild als Link-Vorschau angezeigt. Das Thumbnail wird von Link-Preview-Crawlern ohne Authentifizierung abgerufen — wer die URL teilt, macht damit faktisch dieses Bild öffentlich. Standardmäßig aus; wähle erst ein Heldenbild, das du bewusst öffentlich zeigen möchtest, bevor du diese Option aktivierst.",
"heroRequired": "Wähle zuerst oben ein Heldenbild — diese Option verwendet es als WhatsApp- / Facebook- / Slack-Vorschaubild."
+ },
+ "publishQuietSuccess": "Galerie veröffentlicht. Es wurde keine E-Mail versendet.",
+ "sendGalleryEmail": {
+ "button": "Galerie-E-Mail senden",
+ "help": "Sendet den Galerie-Link an den Kunden. Bei Galerien mit Passwort bestätigen Sie es vorher.",
+ "confirm": "Galerie-E-Mail an {{recipient}} senden?",
+ "success": "Galerie-E-Mail an {{recipient}} eingereiht.",
+ "title": "Galerie-E-Mail senden",
+ "description": "Sendet den Galerie-Link für \"{{eventName}}\" an {{recipient}}.",
+ "passwordHelp": "Die E-Mail enthält genau diesen Text. Geben Sie das Galerie-Passwort erneut ein (oder wählen Sie ein neues) — das Backend hasht es neu, damit der Login weiter funktioniert."
}
},
"settings": {
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 935a8483..9a3688be 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -903,7 +903,11 @@
"passwordLabel": "Gallery password",
"passwordPlaceholder": "Enter the gallery password",
"passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.",
- "errorMinLength": "Password must be at least 6 characters long."
+ "errorMinLength": "Password must be at least 6 characters long.",
+ "notifyLabel": "Send the gallery email now",
+ "notifyHelp": "Uncheck to publish quietly — the gallery goes live and nothing is sent. You can send the email later from this page.",
+ "descriptionQuiet": "Publishing \"{{eventName}}\" makes the gallery accessible. No email will be sent — you can send it later from this page.",
+ "descriptionWhatsapp": "Publishing \"{{eventName}}\" makes the gallery accessible. If WhatsApp is configured, the customer is notified there."
},
"duplicateEvent": "Duplicate gallery",
"duplicateDialog": {
@@ -1054,6 +1058,16 @@
"title": "Use hero photo as social-share preview",
"help": "When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.",
"heroRequired": "Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image."
+ },
+ "publishQuietSuccess": "Gallery published. No email was sent.",
+ "sendGalleryEmail": {
+ "button": "Send gallery email",
+ "help": "Sends the gallery link to the customer. You confirm the password first if the gallery has one.",
+ "confirm": "Send the gallery email to {{recipient}}?",
+ "success": "Gallery email queued to {{recipient}}.",
+ "title": "Send gallery email",
+ "description": "Sends the gallery link for \"{{eventName}}\" to {{recipient}}.",
+ "passwordHelp": "The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the login still works."
}
},
"settings": {
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index bff1bea1..5c5b3823 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -6,7 +6,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Loading } from '../../components/common';
-import { PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
+import { PasswordResetModal, PublishGalleryDialog, SendGalleryEmailDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -60,6 +60,7 @@ export const EventDetailsPage: React.FC = () => {
const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showPublishDialog, setShowPublishDialog] = useState(false);
+ const [showSendEmailDialog, setShowSendEmailDialog] = useState(false);
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState(null);
const [currentPresetName, setCurrentPresetName] = useState('default');
@@ -245,12 +246,22 @@ export const EventDetailsPage: React.FC = () => {
// Publish mutation (Draft mode). Accepts the admin-typed password so the
// gallery_created email can carry the real plaintext (#627).
const publishMutation = useMutation({
- mutationFn: (password?: string) =>
- eventsService.publishEvent(parseInt(id!), password ? { password } : undefined),
- onSuccess: () => {
+ mutationFn: (vars: { password?: string; notifyCustomer?: boolean }) =>
+ eventsService.publishEvent(parseInt(id!), {
+ password: vars.password,
+ notifyCustomer: vars.notifyCustomer,
+ }),
+ onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
- toast.success(t('events.publishSuccess'));
+ // Say which of the two happened — "published" and "published and
+ // emailed your customer" are different enough that a single message
+ // would leave the admin unsure whether anything went out (#1235).
+ toast.success(
+ result?.notified_customer === false
+ ? t('events.publishQuietSuccess', 'Gallery published. No email was sent.')
+ : t('events.publishSuccess'),
+ );
setShowPublishDialog(false);
},
onError: () => {
@@ -258,6 +269,25 @@ export const EventDetailsPage: React.FC = () => {
},
});
+ // Send the gallery email after the fact (#1235). Pairs with publishing
+ // quietly: the address usually arrives later than the gallery does.
+ const sendGalleryEmailMutation = useMutation({
+ mutationFn: (password?: string) =>
+ eventsService.sendGalleryEmail(parseInt(id!), password ? { password } : undefined),
+ onSuccess: (result) => {
+ toast.success(
+ t('events.sendGalleryEmail.success', {
+ recipient: result.recipient,
+ defaultValue: 'Gallery email queued to {{recipient}}.',
+ }),
+ );
+ setShowSendEmailDialog(false);
+ },
+ onError: () => {
+ toast.error(t('errors.somethingWentWrong'));
+ },
+ });
+
// Duplicate mutation (#626). Backend creates a draft inheriting branding +
// behaviour + categories from the source; we navigate to the new event so
// the admin can finish configuring + publish.
@@ -627,6 +657,8 @@ export const EventDetailsPage: React.FC = () => {
setShowPasswordReset={setShowPasswordReset}
setShowPublishDialog={setShowPublishDialog}
setShowDuplicateDialog={setShowDuplicateDialog}
+ onSendGalleryEmail={() => setShowSendEmailDialog(true)}
+ isSendingGalleryEmail={sendGalleryEmailMutation.isPending}
onArchive={() => archiveMutation.mutate()}
isArchiving={archiveMutation.isPending}
isPublishing={publishMutation.isPending}
@@ -706,17 +738,41 @@ export const EventDetailsPage: React.FC = () => {
{showPublishDialog && (
}).customer_accounts || []).length}
isPublishing={publishMutation.isPending}
- onConfirm={(password) => publishMutation.mutate(password)}
+ onConfirm={(password, notifyCustomer) => publishMutation.mutate({ password, notifyCustomer })}
onClose={() => {
if (!publishMutation.isPending) setShowPublishDialog(false);
}}
/>
)}
+ {/* Send Gallery Email Dialog (#1235) — asks for the password for the
+ same reason publish does: the plaintext only exists in this request,
+ and this action is most useful right after a quiet publish, which
+ never collected one. */}
+ {showSendEmailDialog && (
+ sendGalleryEmailMutation.mutate(password)}
+ onClose={() => {
+ if (!sendGalleryEmailMutation.isPending) setShowSendEmailDialog(false);
+ }}
+ />
+ )}
+
{/* Duplicate Event Dialog (#626) — admin types a new event name/date
(+ optional customer); backend clones the source gallery's config
and we navigate to the new draft. */}
diff --git a/frontend/src/pages/admin/event-details/EventActionsCard.tsx b/frontend/src/pages/admin/event-details/EventActionsCard.tsx
index e1ad187a..d30d4ba0 100644
--- a/frontend/src/pages/admin/event-details/EventActionsCard.tsx
+++ b/frontend/src/pages/admin/event-details/EventActionsCard.tsx
@@ -1,9 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { Archive, Send, Copy } from 'lucide-react';
+import { Archive, Send, Copy, Mail } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { PermissionGate } from '../../../components/admin/PermissionGate';
+import { toBoolean } from '../../../utils/parsers';
interface EventActionsCardProps {
event: Event;
@@ -13,6 +14,11 @@ interface EventActionsCardProps {
isPublishing: boolean;
setShowDuplicateDialog: (show: boolean) => void;
isDuplicating: boolean;
+ /** Send the gallery email for an already-published gallery (#1235). */
+ onSendGalleryEmail: () => void;
+ isSendingGalleryEmail: boolean;
+ /** Assigned customer accounts — a recipient even with no inline email. */
+ assignedCustomerCount?: number;
}
export const EventActionsCard: React.FC = ({
@@ -22,10 +28,22 @@ export const EventActionsCard: React.FC = ({
setShowPublishDialog,
isPublishing,
setShowDuplicateDialog,
- isDuplicating
+ isDuplicating,
+ onSendGalleryEmail,
+ isSendingGalleryEmail,
+ assignedCustomerCount = 0
}) => {
const { t } = useTranslation();
+ // Mirror the endpoint's own eligibility rules. Showing a button the backend
+ // is guaranteed to reject just walks the admin through a dialog to reach a
+ // generic error toast — the archived case is already handled by the caller,
+ // which does not render this card at all for archived events.
+ const hasRecipient = !!event.customer_email || assignedCustomerCount > 0;
+ const isExpired = !!event.expires_at && new Date(event.expires_at) <= new Date();
+ const isInactive = !toBoolean(event.is_active, true);
+ const canSendGalleryEmail = hasRecipient && !isExpired && !isInactive;
+
return (
) : (
-
+ <>
+ {/* Send the gallery email after publishing (#1235). The pair to
+ publishing quietly — the address often arrives later than the
+ gallery — and it doubles as a re-send when the first one was
+ lost. Hidden without a recipient, since there is nowhere to
+ send it.
+
+ Its own gate, NOT nested inside the archive one below: the
+ default editor role has events.edit but not events.archive, so
+ nesting hid this action from exactly the people allowed to use
+ the endpoint behind it. */}
+ {/* Assigned accounts count as a recipient: 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 —
+ so hiding the button here made that promise unkeepable. */}
+ {canSendGalleryEmail && (
+
+ }
+ onClick={onSendGalleryEmail}
+ isLoading={isSendingGalleryEmail}
+ className="w-full justify-center"
+ >
+ {t('events.sendGalleryEmail.button', 'Send gallery email')}
+
+
+ {t('events.sendGalleryEmail.help', 'Sends the gallery link to the customer. You confirm the password first if the gallery has one.')}
+