Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7258d5236 | |||
| 96bc2286a5 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -5,6 +5,20 @@ Alle wichtigen Änderungen an diesem Projekt werden hier dokumentiert.
|
|||||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
|
||||||
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-01-16
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Footer-Icon-Auswahl**: Mehrere Icons aus dem `/assets`-Ordner können für den E-Mail-Footer ausgewählt werden
|
||||||
|
- Neuer API-Endpunkt `GET /api/assets` zum Abrufen verfügbarer Assets
|
||||||
|
- Thumbnail-Grid in der Weboberfläche zur Icon-Auswahl
|
||||||
|
- Dynamische Footer-Generierung mit mehreren CID-eingebetteten Bildern
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- `POST /api/send` akzeptiert neues optionales Feld `footerAssets` (Array von Dateinamen)
|
||||||
|
- Footer wird nur noch angezeigt wenn mindestens ein Icon ausgewählt wurde
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.0] - 2026-01-16
|
## [1.0.0] - 2026-01-16
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -5,8 +5,9 @@ Ein Node.js-basierter Mail-Service mit Weboberfläche zum Versenden von E-Mails
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Weboberfläche** zum Versenden von E-Mails (Empfänger, CC, Betreff, Text/HTML)
|
- **Weboberfläche** zum Versenden von E-Mails (Empfänger, CC, Betreff, Text/HTML)
|
||||||
|
- **Footer-Icon-Auswahl** - Mehrere Icons aus dem `/assets`-Ordner auswählbar (Thumbnail-Grid)
|
||||||
- **Persistente Historie** in SQLite-Datenbank
|
- **Persistente Historie** in SQLite-Datenbank
|
||||||
- **Eingebetteter Bild-Footer** via CID-Attachment (wird ohne "Bilder anzeigen" dargestellt)
|
- **Eingebettete Bilder** via CID-Attachment (werden ohne "Bilder anzeigen" dargestellt)
|
||||||
- **REST-API** für programmatischen Zugriff
|
- **REST-API** für programmatischen Zugriff
|
||||||
- **Brevo SMTP** mit DKIM/SPF/DMARC-Unterstützung
|
- **Brevo SMTP** mit DKIM/SPF/DMARC-Unterstützung
|
||||||
|
|
||||||
@@ -25,8 +26,10 @@ mail-service/
|
|||||||
│ ├── index.html # Weboberfläche
|
│ ├── index.html # Weboberfläche
|
||||||
│ ├── style.css # Styling
|
│ ├── style.css # Styling
|
||||||
│ └── script.js # Frontend-Logik
|
│ └── script.js # Frontend-Logik
|
||||||
├── assets/
|
├── assets/ # Footer-Icons (PNG/JPG/GIF/SVG)
|
||||||
│ └── homeicon.png # Footer-Bild (40x40 PNG)
|
│ ├── homeicon.png
|
||||||
|
│ ├── cal.png
|
||||||
|
│ └── ... # Weitere Icons automatisch verfügbar
|
||||||
└── data/
|
└── data/
|
||||||
└── emails.db # SQLite-Datenbank (automatisch erstellt)
|
└── emails.db # SQLite-Datenbank (automatisch erstellt)
|
||||||
```
|
```
|
||||||
@@ -90,11 +93,22 @@ Server läuft auf: http://localhost:3000
|
|||||||
| Methode | Route | Beschreibung |
|
| Methode | Route | Beschreibung |
|
||||||
|---------|-------|--------------|
|
|---------|-------|--------------|
|
||||||
| GET | `/` | Weboberfläche |
|
| GET | `/` | Weboberfläche |
|
||||||
|
| GET | `/api/assets` | Verfügbare Footer-Icons abrufen |
|
||||||
| POST | `/api/send` | E-Mail versenden |
|
| POST | `/api/send` | E-Mail versenden |
|
||||||
| GET | `/api/history` | Historie abrufen (letzte 50) |
|
| GET | `/api/history` | Historie abrufen (letzte 50) |
|
||||||
| DELETE | `/api/history/:id` | Einzelnen Eintrag löschen |
|
| DELETE | `/api/history/:id` | Einzelnen Eintrag löschen |
|
||||||
| DELETE | `/api/history` | Gesamte Historie löschen |
|
| DELETE | `/api/history` | Gesamte Historie löschen |
|
||||||
|
|
||||||
|
### GET /api/assets
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"assets": ["blue.png", "cal.png", "cog.png", "homeicon.png", "psy.png"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### POST /api/send
|
### POST /api/send
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
@@ -104,10 +118,20 @@ Server läuft auf: http://localhost:3000
|
|||||||
"cc": "optional@example.com",
|
"cc": "optional@example.com",
|
||||||
"subject": "Betreff",
|
"subject": "Betreff",
|
||||||
"body": "Nachrichteninhalt",
|
"body": "Nachrichteninhalt",
|
||||||
"isHtml": false
|
"isHtml": false,
|
||||||
|
"footerAssets": ["homeicon.png", "cal.png"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Feld | Typ | Pflicht | Beschreibung |
|
||||||
|
|------|-----|---------|--------------|
|
||||||
|
| to | string | Ja | Empfänger-Adresse |
|
||||||
|
| cc | string | Nein | CC-Adresse |
|
||||||
|
| subject | string | Ja | Betreff |
|
||||||
|
| body | string | Ja | Nachrichteninhalt |
|
||||||
|
| isHtml | boolean | Nein | true = HTML-Format |
|
||||||
|
| footerAssets | string[] | Nein | Icons für Footer (aus /api/assets) |
|
||||||
|
|
||||||
**Response (Erfolg):**
|
**Response (Erfolg):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -493,29 +517,11 @@ sendMail(
|
|||||||
|
|
||||||
### Footer in diesem Projekt anpassen
|
### Footer in diesem Projekt anpassen
|
||||||
|
|
||||||
1. **Bild ändern:** `assets/homeicon.png` ersetzen (empfohlen: 40x40 PNG)
|
1. **Neue Icons hinzufügen:** Einfach Bilddateien in den `assets/`-Ordner legen (PNG, JPG, GIF, SVG). Sie erscheinen automatisch in der Auswahl.
|
||||||
2. **Name ändern:** In `.env` setzen: `MAIL_FOOTER_NAME=Neuer Name`
|
2. **Name ändern:** In `.env` setzen: `MAIL_FOOTER_NAME=Neuer Name`
|
||||||
3. **Layout ändern:** In `src/mailer.js` die Funktion `getHtmlFooter()` bearbeiten:
|
3. **Layout ändern:** In `src/mailer.js` die Funktion `getHtmlFooter(assets)` bearbeiten
|
||||||
|
|
||||||
```javascript
|
**Hinweis:** Der Footer wird nur angezeigt, wenn mindestens ein Icon in der Weboberfläche ausgewählt wurde.
|
||||||
function getHtmlFooter() {
|
|
||||||
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;">
|
|
||||||
<tr>
|
|
||||||
<td style="vertical-align: middle; padding-right: 10px;">
|
|
||||||
<img src="cid:homeicon" alt="Home" width="40" height="40" />
|
|
||||||
</td>
|
|
||||||
<td style="vertical-align: middle;">
|
|
||||||
<strong>Absender</strong><br/>
|
|
||||||
${FOOTER_NAME}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@
|
|||||||
<textarea id="body" name="body" required rows="8" placeholder="Ihre Nachricht..."></textarea>
|
<textarea id="body" name="body" required rows="8" placeholder="Ihre Nachricht..."></textarea>
|
||||||
</div>
|
</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>
|
<button type="submit" id="submitBtn">Senden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
const historyList = document.getElementById('historyList');
|
const historyList = document.getElementById('historyList');
|
||||||
const clearHistoryBtn = document.getElementById('clearHistory');
|
const clearHistoryBtn = document.getElementById('clearHistory');
|
||||||
|
const assetGrid = document.getElementById('assetGrid');
|
||||||
|
|
||||||
// Load history on page load
|
// Load history and assets on page load
|
||||||
loadHistory();
|
loadHistory();
|
||||||
|
loadAssets();
|
||||||
|
|
||||||
// Form submission
|
// Form submission
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
const selectedAssets = Array.from(
|
||||||
|
document.querySelectorAll('input[name="footerAssets"]:checked')
|
||||||
|
).map(cb => cb.value);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
to: formData.get('to'),
|
to: formData.get('to'),
|
||||||
cc: formData.get('cc') || undefined,
|
cc: formData.get('cc') || undefined,
|
||||||
subject: formData.get('subject'),
|
subject: formData.get('subject'),
|
||||||
body: formData.get('body'),
|
body: formData.get('body'),
|
||||||
isHtml: formData.get('format') === 'html'
|
isHtml: formData.get('format') === 'html',
|
||||||
|
footerAssets: selectedAssets
|
||||||
};
|
};
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
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
|
// Render history list
|
||||||
function renderHistory(history) {
|
function renderHistory(history) {
|
||||||
if (!history || history.length === 0) {
|
if (!history || history.length === 0) {
|
||||||
|
|||||||
@@ -235,12 +235,64 @@ button:disabled {
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-message {
|
.empty-message,
|
||||||
|
.loading-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #999;
|
color: #999;
|
||||||
padding: 40px 20px;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.container {
|
.container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -15,23 +15,25 @@ const transporter = nodemailer.createTransport({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const FOOTER_NAME = process.env.MAIL_FOOTER_NAME || 'Joachim Hummel';
|
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 `
|
return `
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;"/>
|
<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;">
|
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #666;">
|
||||||
<tr>
|
<div style="margin-bottom: 8px;">${icons}</div>
|
||||||
<td style="vertical-align: middle; padding-right: 10px;">
|
<strong>Absender</strong><br/>
|
||||||
<img src="cid:homeicon" alt="Home" width="40" height="40" style="display: block;"/>
|
${FOOTER_NAME}
|
||||||
</td>
|
</div>
|
||||||
<td style="vertical-align: middle;">
|
|
||||||
<strong>Absender</strong><br/>
|
|
||||||
${FOOTER_NAME}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +42,19 @@ function getTextFooter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendMail(email) {
|
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 = {
|
const mailOptions = {
|
||||||
from: `"${process.env.MAIL_FROM_NAME}" <${process.env.MAIL_FROM_EMAIL}>`,
|
from: `"${process.env.MAIL_FROM_NAME}" <${process.env.MAIL_FROM_EMAIL}>`,
|
||||||
to: email.to,
|
to: email.to,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
attachments: [
|
attachments
|
||||||
{
|
|
||||||
filename: 'homeicon.png',
|
|
||||||
path: FOOTER_IMAGE,
|
|
||||||
cid: 'homeicon'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (email.cc) {
|
if (email.cc) {
|
||||||
@@ -58,11 +62,10 @@ async function sendMail(email) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (email.isHtml) {
|
if (email.isHtml) {
|
||||||
mailOptions.html = email.body + getHtmlFooter();
|
mailOptions.html = email.body + getHtmlFooter(footerAssets);
|
||||||
} else {
|
} else {
|
||||||
mailOptions.text = email.body + getTextFooter();
|
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(footerAssets);
|
||||||
mailOptions.html = `<pre style="font-family: inherit; white-space: pre-wrap;">${email.body}</pre>` + getHtmlFooter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
const mailer = require('./mailer');
|
const mailer = require('./mailer');
|
||||||
@@ -11,6 +12,7 @@ const PORT = process.env.PORT || 3000;
|
|||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
app.use('/assets', express.static(path.join(__dirname, '..', 'assets')));
|
||||||
|
|
||||||
// Email validation helper
|
// Email validation helper
|
||||||
function isValidEmail(email) {
|
function isValidEmail(email) {
|
||||||
@@ -20,9 +22,29 @@ function isValidEmail(email) {
|
|||||||
|
|
||||||
// API Routes
|
// 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
|
// Send email
|
||||||
app.post('/api/send', async (req, res) => {
|
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
|
// Validation
|
||||||
if (!to || !subject || !body) {
|
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 {
|
try {
|
||||||
const info = await mailer.sendMail(email);
|
const info = await mailer.sendMail(email);
|
||||||
|
|||||||
Reference in New Issue
Block a user