feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to review and hide photos before the gallery is shared with guests. Backend: - Migration 074: add visibility column to photos, client_access_enabled/ client_password_hash/client_share_token to events - Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN - Gallery photo list filters hidden photos for guests, shows all for clients - Visibility toggle endpoints (single + bulk) for client access level - Admin event CRUD supports client access fields - Email template includes client access link + PIN (EN/DE/RU/PT) Frontend: - ClientAccessPage: PIN entry form at /gallery/:slug/client-access - GalleryView: client mode banner, visibility counter, toggle controls - GridGalleryLayout: eye/eye-off overlay per photo for clients - AdminPhotoGrid: visibility badge, bulk Hide/Show buttons - EventDetailsPage: Client Access settings section (toggle, PIN, link) - CreateEventPage: client access toggle + PIN in event creation form - GalleryAuthContext: accessLevel/isClient/clientLogin support - New complete pt-BR locale (pt.json) with all translations - Client access i18n keys for EN, DE, RU, PT
This commit is contained in:
@@ -102,8 +102,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.accessLevel = decoded.accessLevel || 'guest';
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
|
||||
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
|
||||
@@ -124,6 +124,8 @@ const mapEventForApi = (event) => {
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
password_hash: _ph,
|
||||
client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
@@ -208,7 +210,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
// Client access settings (#172)
|
||||
body('client_access_enabled').optional().isBoolean(),
|
||||
body('client_password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -258,7 +263,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor = 'center',
|
||||
// Photo cap
|
||||
photo_cap = null
|
||||
photo_cap = null,
|
||||
// Client access settings (#172)
|
||||
client_access_enabled = false,
|
||||
client_password = null
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -419,7 +427,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||
hero_image_anchor: hero_image_anchor || 'center',
|
||||
photo_cap: photo_cap || null
|
||||
photo_cap: photo_cap || null,
|
||||
// Client access (#172)
|
||||
client_access_enabled: formatBoolean(client_access_enabled),
|
||||
...(client_access_enabled && client_password ? {
|
||||
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
|
||||
client_share_token: crypto.randomBytes(32).toString('hex')
|
||||
} : {})
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -453,21 +467,32 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
// Language detection is handled by email processor
|
||||
|
||||
if (customerEmail) {
|
||||
// Build email data with optional client access info
|
||||
const emailData = {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
};
|
||||
|
||||
// Include client access info in email when enabled (#172)
|
||||
if (client_access_enabled && client_password) {
|
||||
const createdEvent = await db('events').where('id', eventId).first();
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
|
||||
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
|
||||
emailData.client_password = client_password;
|
||||
}
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
email_data: JSON.stringify(emailData),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
@@ -709,7 +734,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
// Client access settings (#172)
|
||||
body('client_access_enabled').optional().isBoolean(),
|
||||
body('client_password').optional().isString(),
|
||||
body('regenerate_client_token').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -788,6 +817,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||
}
|
||||
|
||||
// Handle client access fields (#172)
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
|
||||
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
|
||||
// Auto-generate client share token when first enabling
|
||||
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
|
||||
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
|
||||
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
|
||||
delete updates.client_password;
|
||||
} else {
|
||||
delete updates.client_password;
|
||||
}
|
||||
if (updates.regenerate_client_token) {
|
||||
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
delete updates.regenerate_client_token;
|
||||
|
||||
// Log the update request for debugging
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
|
||||
@@ -587,7 +587,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
const { category_id, visibility } = req.body;
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
@@ -601,6 +601,13 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
|
||||
// Handle visibility update (#172)
|
||||
if (visibility !== undefined) {
|
||||
if (['visible', 'hidden'].includes(visibility)) {
|
||||
updateData.visibility = visibility;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
@@ -736,6 +743,13 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
|
||||
// Handle visibility update (#172)
|
||||
if (updates.visibility !== undefined) {
|
||||
if (['visible', 'hidden'].includes(updates.visibility)) {
|
||||
updateData.visibility = updates.visibility;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.category_id !== undefined) {
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
|
||||
@@ -290,6 +290,82 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
});
|
||||
|
||||
// Client access login (PIN-based)
|
||||
router.post('/gallery/:slug/client-login', [
|
||||
body('password').notEmpty().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug } = req.params;
|
||||
const { password } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event || !event.client_access_enabled || !event.client_password_hash) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.client_password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
accessLevel: 'client',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setGalleryAuthCookies(res, token, event.slug);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: true
|
||||
},
|
||||
accessLevel: 'client'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Client login error:', error);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Share link authentication (token-based)
|
||||
router.post('/gallery/share-login', [
|
||||
body('slug').notEmpty().trim(),
|
||||
|
||||
@@ -198,10 +198,18 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Build the query with sorting
|
||||
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||
const isClient = req.accessLevel === 'client';
|
||||
let photosQuery = db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select('photos.*');
|
||||
|
||||
// Guests only see visible photos; clients see all
|
||||
if (!isClient) {
|
||||
photosQuery = photosQuery.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sort option
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||
@@ -414,12 +422,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// EXIF capture date
|
||||
captured_at: photo.captured_at || null,
|
||||
// Media type
|
||||
media_type: photo.media_type || null,
|
||||
mime_type: photo.mime_type || null,
|
||||
duration: photo.duration || null,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
favorite_count: photo.favorite_count || 0,
|
||||
// Visibility (only included for clients)
|
||||
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -429,24 +445,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoId } = req.params;
|
||||
const { visibility } = req.body;
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.update({ visibility });
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoIds, visibility } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const count = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', req.event.id)
|
||||
.update({ visibility });
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error bulk updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download single photo
|
||||
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
@@ -745,11 +828,15 @@ router.get('/:slug/photo/:photoId',
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
@@ -935,6 +1022,11 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
@@ -1013,6 +1105,11 @@ router.get('/:slug/hero/:photoId',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video - videos don't get hero images
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
|
||||
@@ -353,6 +353,53 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
|
||||
// Inject client access section if client_link is provided (#172)
|
||||
if (processedVariables.client_link) {
|
||||
const clientAccessI18n = {
|
||||
de: {
|
||||
label: 'Kundenzugang (Privat)',
|
||||
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
|
||||
link: 'Kundenzugang öffnen',
|
||||
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
|
||||
},
|
||||
ru: {
|
||||
label: 'Доступ клиента (Личный)',
|
||||
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
|
||||
link: 'Открыть доступ клиента',
|
||||
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||
},
|
||||
pt: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||
link: 'Abrir Acesso do Cliente',
|
||||
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
|
||||
},
|
||||
en: {
|
||||
label: 'Client Access (Private)',
|
||||
desc: 'Review and manage photo visibility before sharing with guests:',
|
||||
link: 'Open Client Access',
|
||||
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
|
||||
},
|
||||
};
|
||||
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
|
||||
const clientAccessLabel = ci18n.label;
|
||||
const clientAccessDesc = ci18n.desc;
|
||||
const clientAccessLink = ci18n.link;
|
||||
const clientAccessWarning = ci18n.warning;
|
||||
const pinLabel = 'PIN';
|
||||
|
||||
htmlBody += `
|
||||
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong style="font-size: 15px;">🔒 ${clientAccessLabel}</strong>
|
||||
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
|
||||
<p style="margin: 8px 0;">
|
||||
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
|
||||
</p>
|
||||
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
|
||||
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${clientAccessWarning}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user