Add footer asset selector for email customization

- New /api/assets endpoint to list available icons from assets folder
- Dynamic footer generation with multiple selectable icons
- Thumbnail grid UI for asset selection in the mail form
- Selected icons are embedded as CID attachments in emails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 22:34:11 +00:00
parent 6299d4e815
commit 96bc2286a5
5 changed files with 155 additions and 28 deletions

View File

@@ -43,6 +43,13 @@
<textarea id="body" name="body" required rows="8" placeholder="Ihre Nachricht..."></textarea>
</div>
<div class="form-group">
<label>Footer-Icons</label>
<div id="assetGrid" class="asset-grid">
<p class="loading-message">Lade Assets...</p>
</div>
</div>
<button type="submit" id="submitBtn">Senden</button>
</form>

View File

@@ -4,21 +4,28 @@ document.addEventListener('DOMContentLoaded', () => {
const submitBtn = document.getElementById('submitBtn');
const historyList = document.getElementById('historyList');
const clearHistoryBtn = document.getElementById('clearHistory');
const assetGrid = document.getElementById('assetGrid');
// Load history on page load
// Load history and assets on page load
loadHistory();
loadAssets();
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const selectedAssets = Array.from(
document.querySelectorAll('input[name="footerAssets"]:checked')
).map(cb => cb.value);
const data = {
to: formData.get('to'),
cc: formData.get('cc') || undefined,
subject: formData.get('subject'),
body: formData.get('body'),
isHtml: formData.get('format') === 'html'
isHtml: formData.get('format') === 'html',
footerAssets: selectedAssets
};
submitBtn.disabled = true;
@@ -80,6 +87,42 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// Load assets from server
async function loadAssets() {
try {
const response = await fetch('/api/assets');
const result = await response.json();
if (result.success) {
renderAssetGrid(result.assets);
} else {
assetGrid.innerHTML = '<p class="empty-message">Fehler beim Laden der Assets</p>';
}
} catch (error) {
console.error('Error loading assets:', error);
assetGrid.innerHTML = '<p class="empty-message">Fehler beim Laden der Assets</p>';
}
}
// Render asset grid
function renderAssetGrid(assets) {
if (!assets || assets.length === 0) {
assetGrid.innerHTML = '<p class="empty-message">Keine Assets verfügbar</p>';
return;
}
assetGrid.innerHTML = assets.map(filename => {
const name = filename.replace(/\.[^.]+$/, '');
return `
<label class="asset-item">
<input type="checkbox" name="footerAssets" value="${filename}">
<img src="/assets/${filename}" alt="${name}">
<span class="asset-name">${name}</span>
</label>
`;
}).join('');
}
// Render history list
function renderHistory(history) {
if (!history || history.length === 0) {

View File

@@ -235,12 +235,64 @@ button:disabled {
background: none;
}
.empty-message {
.empty-message,
.loading-message {
text-align: center;
color: #999;
padding: 40px 20px;
}
/* Asset Grid */
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 10px;
padding: 10px;
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
}
.asset-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.asset-item:hover {
background: #eee;
}
.asset-item img {
width: 40px;
height: 40px;
object-fit: contain;
}
.asset-item .asset-name {
font-size: 11px;
color: #666;
margin-top: 4px;
text-align: center;
word-break: break-all;
}
.asset-item:has(input:checked) {
border-color: #3498db;
background: #e7f3fd;
}
.asset-item input[type="checkbox"] {
position: absolute;
opacity: 0;
pointer-events: none;
}
@media (max-width: 900px) {
.container {
flex-direction: column;

View File

@@ -15,23 +15,25 @@ const transporter = nodemailer.createTransport({
});
const FOOTER_NAME = process.env.MAIL_FOOTER_NAME || 'Joachim Hummel';
const FOOTER_IMAGE = path.join(__dirname, '..', 'assets', 'homeicon.png');
function getHtmlFooter() {
function getHtmlFooter(assets) {
if (!assets || assets.length === 0) {
return '';
}
const icons = assets.map(filename => {
const cid = filename.replace(/\.[^.]+$/, '');
return `<img src="cid:${cid}" alt="${cid}" width="40" height="40" style="display:inline-block; margin-right:8px;"/>`;
}).join('');
return `
<br/><br/>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;"/>
<table cellpadding="0" cellspacing="0" style="font-family: Arial, sans-serif; font-size: 14px; color: #666;">
<tr>
<td style="vertical-align: middle; padding-right: 10px;">
<img src="cid:homeicon" alt="Home" width="40" height="40" style="display: block;"/>
</td>
<td style="vertical-align: middle;">
<strong>Absender</strong><br/>
${FOOTER_NAME}
</td>
</tr>
</table>
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #666;">
<div style="margin-bottom: 8px;">${icons}</div>
<strong>Absender</strong><br/>
${FOOTER_NAME}
</div>
`;
}
@@ -40,17 +42,19 @@ function getTextFooter() {
}
async function sendMail(email) {
const footerAssets = email.footerAssets || [];
const attachments = footerAssets.map(filename => ({
filename,
path: path.join(__dirname, '..', 'assets', filename),
cid: filename.replace(/\.[^.]+$/, '')
}));
const mailOptions = {
from: `"${process.env.MAIL_FROM_NAME}" <${process.env.MAIL_FROM_EMAIL}>`,
to: email.to,
subject: email.subject,
attachments: [
{
filename: 'homeicon.png',
path: FOOTER_IMAGE,
cid: 'homeicon'
}
]
attachments
};
if (email.cc) {
@@ -58,11 +62,10 @@ async function sendMail(email) {
}
if (email.isHtml) {
mailOptions.html = email.body + getHtmlFooter();
mailOptions.html = email.body + getHtmlFooter(footerAssets);
} else {
mailOptions.text = email.body + getTextFooter();
// Also send HTML version with footer for better display
mailOptions.html = `<pre style="font-family: inherit; white-space: pre-wrap;">${email.body}</pre>` + getHtmlFooter();
mailOptions.html = `<pre style="font-family: inherit; white-space: pre-wrap;">${email.body}</pre>` + getHtmlFooter(footerAssets);
}
const info = await transporter.sendMail(mailOptions);

View File

@@ -1,6 +1,7 @@
require('dotenv').config();
const express = require('express');
const fs = require('fs');
const path = require('path');
const db = require('./database');
const mailer = require('./mailer');
@@ -11,6 +12,7 @@ const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/assets', express.static(path.join(__dirname, '..', 'assets')));
// Email validation helper
function isValidEmail(email) {
@@ -20,9 +22,29 @@ function isValidEmail(email) {
// API Routes
// Get available assets
app.get('/api/assets', (req, res) => {
const assetsDir = path.join(__dirname, '..', 'assets');
const allowedExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg'];
try {
const files = fs.readdirSync(assetsDir);
const assets = files
.filter(file => {
const ext = path.extname(file).toLowerCase();
return allowedExtensions.includes(ext);
})
.sort();
res.json({ success: true, assets });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Send email
app.post('/api/send', async (req, res) => {
const { to, cc, subject, body, isHtml } = req.body;
const { to, cc, subject, body, isHtml, footerAssets } = req.body;
// Validation
if (!to || !subject || !body) {
@@ -46,7 +68,7 @@ app.post('/api/send', async (req, res) => {
});
}
const email = { to, cc, subject, body, isHtml: !!isHtml };
const email = { to, cc, subject, body, isHtml: !!isHtml, footerAssets: footerAssets || [] };
try {
const info = await mailer.sendMail(email);