feat: Persist outbound emails, fix @UPDATE_TEAM_MEMBER parser, add per-entry delete

- Add sent_emails table to DB for persistent outbox logging
- send_email() now writes every outgoing mail (incl. errors) to sent_emails
- parse_agent_commands() passes agent_key/task_id as triggered_by metadata
- Fix @UPDATE_TEAM_MEMBER parser: now matches Identifier/TelegramID/Role/etc.
  format from system prompt (was expecting Email/Field/Value — never matched)
- update_team_member() called correctly via **kwargs (was positional args bug)
- Set Piotr telegram_id=1578034974 directly in DB
- email_log.html: two-tab UI (Inbox Journal + Outbox), click-to-expand body
- emails.html: per-message delete button in inbox list
- New routes: DELETE inbox (IMAP expunge), journal entry, sent entry
This commit is contained in:
eric 2026-02-21 18:43:21 +00:00
parent 99df910497
commit f6ad727bf0
3 changed files with 340 additions and 165 deletions

244
app.py
View file

@ -290,6 +290,20 @@ def init_journal():
) )
""") """)
# Outbox-Tabelle für ausgehende Emails
con.execute("""
CREATE TABLE IF NOT EXISTS sent_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
to_address TEXT NOT NULL,
subject TEXT,
body TEXT,
sent_at TEXT NOT NULL,
triggered_by TEXT,
task_id INTEGER,
status TEXT DEFAULT 'sent'
)
""")
# App-Settings Tabelle für globale Einstellungen # App-Settings Tabelle für globale Einstellungen
con.execute(""" con.execute("""
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
@ -954,13 +968,13 @@ def respond_to_message(message_id, response):
return True return True
return False return False
def parse_agent_commands(agent_key, response_text): def parse_agent_commands(agent_key, response_text, task_id=None):
"""Parst Agent-Antwort nach Orchestrator-Kommandos und führt sie aus.""" """Parst Agent-Antwort nach Orchestrator-Kommandos und führt sie aus."""
import re import re
# ASK_ORCHESTRATOR: Agent stellt Frage an Orchestrator # ASK_ORCHESTRATOR: Agent stellt Frage an Orchestrator
ask_requests = re.findall( ask_requests = re.findall(
r'@ASK_ORCHESTRATOR\s*\nQuestion:\s*([^\n]+)\s*\nContext:\s*([^@]+)@END', r'@ASK_ORCHESTRATOR\s*\nQuestion:\s*([^\n]+)\s*\nContext:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -986,7 +1000,7 @@ Die Antwort wird an {agent_key} zurückgegeben.""",
# CREATE_SUBTASK: Agent möchte Subtask erstellen # CREATE_SUBTASK: Agent möchte Subtask erstellen
subtask_requests = re.findall( subtask_requests = re.findall(
r'@CREATE_SUBTASK\s*\nTask:\s*([^\n]+)\s*\nRequirements:\s*([^@]+)@END', r'@CREATE_SUBTASK\s*\nTask:\s*([^\n]+)\s*\nRequirements:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -1003,7 +1017,7 @@ Die Antwort wird an {agent_key} zurückgegeben.""",
# SUGGEST_AGENT: Agent schlägt neuen Agent vor # SUGGEST_AGENT: Agent schlägt neuen Agent vor
suggest_requests = re.findall( suggest_requests = re.findall(
r'@SUGGEST_AGENT\s*\nRole:\s*([^\n]+)\s*\nSkills:\s*([^\n]+)\s*\nReason:\s*([^@]+)@END', r'@SUGGEST_AGENT\s*\nRole:\s*([^\n]+)\s*\nSkills:\s*([^\n]+)\s*\nReason:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -1033,7 +1047,7 @@ Bitte entscheide ob dieser Agent erstellt werden soll.""",
# READ_KNOWLEDGE: Agent möchte Wissensdatenbank durchsuchen # READ_KNOWLEDGE: Agent möchte Wissensdatenbank durchsuchen
read_kb_requests = re.findall( read_kb_requests = re.findall(
r'@READ_KNOWLEDGE\s*\nTopic:\s*([^@]+)@END', r'@READ_KNOWLEDGE\s*\nTopic:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -1062,7 +1076,7 @@ Bitte entscheide ob dieser Agent erstellt werden soll.""",
# SEND_EMAIL: Orchestrator sendet Email an Team-Member # SEND_EMAIL: Orchestrator sendet Email an Team-Member
send_email_requests = re.findall( send_email_requests = re.findall(
r'@SEND_EMAIL\s*\nTo:\s*([^\n]+)\s*\nSubject:\s*([^\n]+)\s*\nBody:\s*([^@]+)@END', r'@SEND_EMAIL\s*\nTo:\s*([^\n]+)\s*\nSubject:\s*([^\n]+)\s*\nBody:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -1072,7 +1086,8 @@ Bitte entscheide ob dieser Agent erstellt werden soll.""",
body_clean = body.strip() body_clean = body.strip()
# Versuche Email zu senden # Versuche Email zu senden
success, message = send_email(to_clean, subject_clean, body_clean) success, message = send_email(to_clean, subject_clean, body_clean,
triggered_by=f'agent:{agent_key}', task_id=task_id)
if success: if success:
logger.info(f"[AgentCmd] Email gesendet an {to_clean}: {subject_clean}") logger.info(f"[AgentCmd] Email gesendet an {to_clean}: {subject_clean}")
else: else:
@ -1080,7 +1095,7 @@ Bitte entscheide ob dieser Agent erstellt werden soll.""",
# SEND_TELEGRAM: Orchestrator sendet Telegram-Nachricht # SEND_TELEGRAM: Orchestrator sendet Telegram-Nachricht
send_telegram_requests = re.findall( send_telegram_requests = re.findall(
r'@SEND_TELEGRAM\s*\nTo:\s*([^\n]+)\s*\nMessage:\s*([^@]+)@END', r'@SEND_TELEGRAM\s*\nTo:\s*([^\n]+)\s*\nMessage:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -1116,7 +1131,7 @@ Bitte entscheide ob dieser Agent erstellt werden soll.""",
# ADD_TEAM_MEMBER: Füge neues Team-Mitglied hinzu # ADD_TEAM_MEMBER: Füge neues Team-Mitglied hinzu
add_member_requests = re.findall( add_member_requests = re.findall(
r'@ADD_TEAM_MEMBER\s*\nName:\s*([^\n]+)\s*\nEmail:\s*([^\n]+)\s*\nRole:\s*([^\n]+)\s*\nResponsibilities:\s*([^@]+)@END', r'@ADD_TEAM_MEMBER\s*\nName:\s*([^\n]+)\s*\nEmail:\s*([^\n]+)\s*\nRole:\s*([^\n]+)\s*\nResponsibilities:\s*(.*?)@END',
response_text, response_text,
re.DOTALL re.DOTALL
) )
@ -1133,50 +1148,41 @@ Bitte entscheide ob dieser Agent erstellt werden soll.""",
logger.warning(f"[AgentCmd] Team-Member konnte nicht hinzugefügt werden: {name_clean}") logger.warning(f"[AgentCmd] Team-Member konnte nicht hinzugefügt werden: {name_clean}")
# UPDATE_TEAM_MEMBER: Aktualisiere Team-Mitglied # UPDATE_TEAM_MEMBER: Aktualisiere Team-Mitglied
update_member_requests = re.findall( # Format: @UPDATE_TEAM_MEMBER\nIdentifier: <email oder name>\n[Feld: Wert]\n...\n@END
r'@UPDATE_TEAM_MEMBER\s*\nEmail:\s*([^\n]+)\s*\nField:\s*([^\n]+)\s*\nValue:\s*([^@]+)@END', ALLOWED_UPDATE_FIELDS = {
response_text, 'name': 'name',
re.DOTALL 'role': 'role',
) 'responsibilities': 'responsibilities',
for email, field, value in update_member_requests: 'email': 'email',
email_clean = email.strip() 'telegramid': 'telegram_id',
field_clean = field.strip().lower() 'telegram_id': 'telegram_id',
value_clean = value.strip() 'phone': 'phone',
}
# Hole aktuelles Team-Member for block in re.findall(r'@UPDATE_TEAM_MEMBER\s*\n(.*?)@END', response_text, re.DOTALL):
con = sqlite3.connect(EMAIL_JOURNAL_DB) lines = [l.strip() for l in block.strip().splitlines() if l.strip()]
member = con.execute( identifier = None
"SELECT name, role, email, responsibilities FROM team_members WHERE LOWER(email) = ?", kwargs = {}
(email_clean.lower(),) for line in lines:
).fetchone() if ':' not in line:
continue
if member: key, _, val = line.partition(':')
# Update je nach Field key_norm = key.strip().lower().replace(' ', '_')
updates = { val_clean = val.strip()
'name': member[0], if key_norm == 'identifier':
'role': member[1], identifier = val_clean
'email': member[2], elif key_norm in ALLOWED_UPDATE_FIELDS:
'responsibilities': member[3] kwargs[ALLOWED_UPDATE_FIELDS[key_norm]] = val_clean
} if not identifier:
logger.warning("[AgentCmd] @UPDATE_TEAM_MEMBER ohne Identifier ignoriert")
if field_clean in updates: continue
updates[field_clean] = value_clean if not kwargs:
success = update_team_member( logger.warning(f"[AgentCmd] @UPDATE_TEAM_MEMBER für '{identifier}' ohne Felder ignoriert")
email_clean, continue
updates['name'], success = update_team_member(identifier, **kwargs)
updates['role'], if success:
updates['responsibilities'] logger.info(f"[AgentCmd] Team-Member aktualisiert: {identifier} - {list(kwargs.keys())}")
)
if success:
logger.info(f"[AgentCmd] Team-Member aktualisiert: {email_clean} - {field_clean}")
else:
logger.error(f"[AgentCmd] Update fehlgeschlagen für {email_clean}")
else:
logger.warning(f"[AgentCmd] Unbekanntes Field: {field_clean}")
else: else:
logger.warning(f"[AgentCmd] Team-Member nicht gefunden: {email_clean}") logger.error(f"[AgentCmd] Update fehlgeschlagen für '{identifier}'")
con.close()
def create_new_agent(agent_key, role, skills): def create_new_agent(agent_key, role, skills):
"""Erstellt dynamisch einen neuen Agenten.""" """Erstellt dynamisch einen neuen Agenten."""
@ -1373,8 +1379,9 @@ def get_email_body(email_id):
return f'Fehler beim Abrufen der Email: {str(e)}' return f'Fehler beim Abrufen der Email: {str(e)}'
def send_email(to_address, subject, body): def send_email(to_address, subject, body, triggered_by='manual', task_id=None):
"""Sendet eine Email via SMTP""" """Sendet eine Email via SMTP und persistiert sie in der Outbox."""
now = datetime.now().isoformat()
try: try:
if not EMAIL_CONFIG['email_address'] or not EMAIL_CONFIG['email_password']: if not EMAIL_CONFIG['email_address'] or not EMAIL_CONFIG['email_password']:
return False, 'Email-Konfiguration erforderlich' return False, 'Email-Konfiguration erforderlich'
@ -1396,9 +1403,29 @@ def send_email(to_address, subject, body):
server.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password']) server.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password'])
server.send_message(msg) server.send_message(msg)
server.quit() server.quit()
# Ausgehende Email persistieren
con = sqlite3.connect(EMAIL_JOURNAL_DB)
con.execute(
"INSERT INTO sent_emails (to_address, subject, body, sent_at, triggered_by, task_id, status) VALUES (?,?,?,?,?,?,'sent')",
(to_address, subject, body, now, triggered_by, task_id)
)
con.commit()
con.close()
return True, 'Email erfolgreich versendet' return True, 'Email erfolgreich versendet'
except Exception as e: except Exception as e:
# Fehlgeschlagene Versuche auch loggen
try:
con = sqlite3.connect(EMAIL_JOURNAL_DB)
con.execute(
"INSERT INTO sent_emails (to_address, subject, body, sent_at, triggered_by, task_id, status) VALUES (?,?,?,?,?,?,'error')",
(to_address, subject, body, now, triggered_by, task_id)
)
con.commit()
con.close()
except Exception:
pass
return False, f'Fehler beim Versenden: {str(e)}' return False, f'Fehler beim Versenden: {str(e)}'
def is_whitelisted(sender_address): def is_whitelisted(sender_address):
@ -1547,33 +1574,24 @@ async def telegram_message_handler(update: Update, context: ContextTypes.DEFAULT
def send_telegram_message(chat_id: int, message: str): def send_telegram_message(chat_id: int, message: str):
"""Sendet eine Nachricht an einen Telegram-Chat.""" """Sendet eine Nachricht an einen Telegram-Chat via direktem HTTP-Request."""
global telegram_app token = TELEGRAM_CONFIG.get('bot_token')
if not token:
if not telegram_app or not TELEGRAM_CONFIG['bot_token']: logging.warning("[Telegram] Cannot send message: Bot token not configured")
logging.warning("[Telegram] Cannot send message: Bot not configured")
return False return False
try: try:
import asyncio import requests as req
# In die laufende Event-Loop des Telegram-Threads senden (thread-safe) r = req.post(
loop = telegram_app.updater.application.updater._network_loop if hasattr(telegram_app, 'updater') else None f"https://api.telegram.org/bot{token}/sendMessage",
if loop is None: json={'chat_id': chat_id, 'text': message},
# Fallback: Loop des Telegram-Threads direkt holen timeout=15
loop = telegram_app.loop if hasattr(telegram_app, 'loop') else None )
if r.ok:
if loop and loop.is_running(): logging.info(f"[Telegram] Nachricht an {chat_id} gesendet.")
future = asyncio.run_coroutine_threadsafe( return True
telegram_app.bot.send_message(chat_id=chat_id, text=message),
loop
)
future.result(timeout=30)
else: else:
# Letzter Fallback: eigene Loop (kein Telegram-Bot läuft) logging.error(f"[Telegram] API Fehler: {r.status_code} {r.text}")
loop = asyncio.new_event_loop() return False
loop.run_until_complete(telegram_app.bot.send_message(chat_id=chat_id, text=message))
loop.close()
return True
except Exception as e: except Exception as e:
logging.error(f"[Telegram] Error sending message: {e}") logging.error(f"[Telegram] Error sending message: {e}")
return False return False
@ -2012,6 +2030,12 @@ def process_beat_tasks():
task['response'] = response task['response'] = response
logger.info("[TaskBeat] Task #%d abgeschlossen.", task['id']) logger.info("[TaskBeat] Task #%d abgeschlossen.", task['id'])
# Agent-Kommandos parsen (@SEND_EMAIL, @UPDATE_TEAM_MEMBER, etc.)
try:
parse_agent_commands('orchestrator', response, task_id=task['id'])
except Exception as e:
logger.error("[TaskBeat] parse_agent_commands Fehler: %s", str(e))
# Telegram-Antwort senden # Telegram-Antwort senden
if task.get('type') == 'telegram' and task.get('telegram_chat_id'): if task.get('type') == 'telegram' and task.get('telegram_chat_id'):
try: try:
@ -3006,13 +3030,69 @@ def view_email(email_id):
return {'content': body} return {'content': body}
@app.route('/emails/inbox/<imap_uid>/delete', methods=['POST'])
@login_required
def delete_inbox_email(imap_uid):
"""Löscht eine einzelne Email aus dem IMAP-Posteingang (verschiebt in Trash)."""
if not (EMAIL_CONFIG['email_address'] and EMAIL_CONFIG['email_password']):
flash('Email-Konfiguration erforderlich', 'danger')
return redirect(url_for('emails'))
try:
mail = imaplib.IMAP4_SSL(EMAIL_CONFIG['imap_server'], EMAIL_CONFIG['imap_port'])
mail.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password'])
mail.select('INBOX')
mail.store(imap_uid, '+FLAGS', '\\Deleted')
mail.expunge()
mail.close()
mail.logout()
flash('Email aus Posteingang gelöscht.', 'success')
except Exception as e:
flash(f'Fehler beim Löschen: {e}', 'danger')
return redirect(url_for('emails'))
@app.route('/emails/journal/<path:message_id>/delete', methods=['POST'])
@login_required
def delete_journal_entry(message_id):
"""Löscht einen einzelnen Journal-Eintrag (eingehende Emails)."""
con = sqlite3.connect(EMAIL_JOURNAL_DB)
con.execute("DELETE FROM email_journal WHERE message_id = ?", (message_id,))
con.commit()
con.close()
flash('Journal-Eintrag gelöscht.', 'success')
return redirect(url_for('email_log_view'))
@app.route('/emails/sent/<int:sent_id>/delete', methods=['POST'])
@login_required
def delete_sent_email(sent_id):
"""Löscht einen einzelnen Outbox-Eintrag."""
con = sqlite3.connect(EMAIL_JOURNAL_DB)
con.execute("DELETE FROM sent_emails WHERE id = ?", (sent_id,))
con.commit()
con.close()
flash('Gesendete Email aus Log gelöscht.', 'success')
return redirect(url_for('email_log_view'))
@app.route('/email-log') @app.route('/email-log')
@login_required @login_required
def email_log_view(): def email_log_view():
"""Zeigt das Email-Verarbeitungs-Log.""" """Zeigt Inbox-Journal und Outbox-Log."""
with email_log_lock: # Eingehende: aus DB
log_entries = list(reversed(email_log)) # Neueste zuerst con = sqlite3.connect(EMAIL_JOURNAL_DB)
return render_template('email_log.html', agents=AGENTS, log_entries=log_entries) con.row_factory = sqlite3.Row
journal_rows = con.execute(
"SELECT * FROM email_journal ORDER BY received_at DESC"
).fetchall()
# Ausgehende: aus DB
sent_rows = con.execute(
"SELECT * FROM sent_emails ORDER BY sent_at DESC"
).fetchall()
con.close()
return render_template('email_log.html', agents=AGENTS,
journal_rows=journal_rows,
sent_rows=sent_rows)
@app.route('/settings', methods=['GET', 'POST']) @app.route('/settings', methods=['GET', 'POST'])

View file

@ -4,87 +4,177 @@
{% block content %} {% block content %}
<div class="page-header d-flex align-items-center justify-content-between"> <div class="page-header d-flex align-items-center justify-content-between">
<div> <div>
<h1>Email-Verarbeitungs-Log</h1> <h1>Email-Log</h1>
<p>Automatisch verarbeitete Emails und Antworten</p> <p>Posteingang-Journal und gesendete Emails</p>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-secondary" style="font-size:.85rem;">{{ log_entries|length }} Einträge</span>
<button class="btn btn-secondary btn-sm" onclick="location.reload()">Aktualisieren</button>
</div> </div>
<button class="btn btn-secondary btn-sm" onclick="location.reload()">Aktualisieren</button>
</div> </div>
<div class="mb-3" style="font-size:.8rem;color:var(--text-muted);"> <!-- Tabs -->
<span class="status-replied me-3">✓ replied</span> — Auto-Reply versendet &nbsp;|&nbsp; <ul class="nav nav-tabs mb-3" id="emailLogTabs">
<span class="status-skipped me-3">— skipped</span> — Nicht auf Whitelist &nbsp;|&nbsp; <li class="nav-item">
<span class="status-error">✗ error</span> — Fehler beim Versenden <a class="nav-link active" data-bs-toggle="tab" href="#tab-inbox">
</div> Posteingang / Journal
<span class="badge bg-secondary ms-1">{{ journal_rows|length }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-sent">
Gesendet
<span class="badge bg-primary ms-1">{{ sent_rows|length }}</span>
</a>
</li>
</ul>
{% if not log_entries %} <div class="tab-content">
<div class="card">
<div class="card-body text-center py-5" style="color:var(--text-muted);"> <!-- ── TAB: Posteingang / Journal ── -->
<p style="font-size:2rem;">📭</p> <div class="tab-pane fade show active" id="tab-inbox">
<p>Noch keine Emails verarbeitet.<br><small>Der Poller prüft alle 2 Minuten den Posteingang.</small></p> {% if not journal_rows %}
</div> <div class="card">
</div> <div class="card-body text-center py-5" style="color:var(--text-muted);">
{% else %} <p style="font-size:2rem;">📭</p>
<div class="card"> <p>Keine Journal-Einträge vorhanden.</p>
<div class="card-body p-0"> </div>
<div class="table-responsive">
<table class="table table-hover log-table mb-0">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Von</th>
<th>Betreff</th>
<th>Agent</th>
<th>Status</th>
<th>Antwort-Vorschau</th>
</tr>
</thead>
<tbody>
{% for entry in log_entries %}
<tr>
<td style="white-space:nowrap;"><small style="color:var(--text-muted);">{{ entry.timestamp }}</small></td>
<td><small>{{ entry.from }}</small></td>
<td>{{ entry.subject }}</td>
<td>
{% if entry.agent %}
<span class="badge bg-primary badge-agent">{{ entry.agent }}</span>
{% else %}
<span style="color:var(--text-muted);"></span>
{% endif %}
</td>
<td>
{% if entry.status == 'replied' or entry.status == 'completed' %}
<span class="status-replied">✓ replied</span>
{% elif entry.status == 'skipped' %}
<span class="status-skipped">— skipped</span>
{% elif entry.status == 'error' %}
<span class="status-error">✗ error</span>
{% elif entry.status == 'queued' %}
<span style="color:var(--warning);">⏳ queued</span>
{% else %}
<span style="color:var(--text-muted);">{{ entry.status }}</span>
{% endif %}
</td>
<td>
{% if entry.response_preview %}
<div class="response-preview">{{ entry.response_preview }}</div>
{% else %}
<small style="color:var(--text-muted);"></small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover log-table mb-0">
<thead>
<tr>
<th>Empfangen</th>
<th>Von</th>
<th>Betreff</th>
<th>Agent</th>
<th>Status</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody>
{% for row in journal_rows %}
<tr>
<td style="white-space:nowrap;">
<small style="color:var(--text-muted);">{{ row.received_at[:16] if row.received_at else '—' }}</small>
</td>
<td><small>{{ row.sender or '—' }}</small></td>
<td>{{ row.subject or '—' }}</td>
<td>
{% if row.agent_key %}
<span class="badge bg-primary badge-agent">{{ row.agent_key }}</span>
{% else %}
<span style="color:var(--text-muted);"></span>
{% endif %}
</td>
<td>
{% if row.status == 'completed' %}
<span class="status-replied">✓ completed</span>
{% elif row.status == 'skipped' %}
<span class="status-skipped">— skipped</span>
{% elif row.status == 'error' %}
<span class="status-error">✗ error</span>
{% elif row.status == 'queued' %}
<span style="color:var(--warning);">⏳ queued</span>
{% else %}
<span style="color:var(--text-muted);">{{ row.status }}</span>
{% endif %}
</td>
<td>
<form method="POST" action="/emails/journal/{{ row.message_id | urlencode }}/delete"
onsubmit="return confirm('Journal-Eintrag löschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div> </div>
</div>
{% endif %}
<div class="mt-3" style="font-size:.78rem;color:var(--text-muted);"> <!-- ── TAB: Gesendet ── -->
Whitelist: <strong>eric.fischer@signtime.media</strong>, <strong>p.dyderski@live.at</strong>, <div class="tab-pane fade" id="tab-sent">
<strong>georg.tschare@gmail.com</strong>, <strong>*@diversityball.at</strong> · Max. 50 Einträge {% if not sent_rows %}
</div> <div class="card">
<div class="card-body text-center py-5" style="color:var(--text-muted);">
<p style="font-size:2rem;">📤</p>
<p>Noch keine gesendeten Emails im Log.<br>
<small>Ab jetzt werden alle ausgehenden Emails hier gespeichert.</small>
</p>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover log-table mb-0">
<thead>
<tr>
<th>Gesendet</th>
<th>An</th>
<th>Betreff</th>
<th>Ausgelöst von</th>
<th>Status</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody>
{% for row in sent_rows %}
<tr style="cursor:pointer;" onclick="toggleBody({{ row.id }})">
<td style="white-space:nowrap;">
<small style="color:var(--text-muted);">{{ row.sent_at[:16] if row.sent_at else '—' }}</small>
</td>
<td><small>{{ row.to_address or '—' }}</small></td>
<td>{{ row.subject or '—' }}</td>
<td>
<small style="color:var(--text-muted);">
{{ row.triggered_by or 'manual' }}
{% if row.task_id %}<span class="badge bg-secondary ms-1">Task #{{ row.task_id }}</span>{% endif %}
</small>
</td>
<td>
{% if row.status == 'sent' %}
<span class="status-replied">✓ gesendet</span>
{% elif row.status == 'error' %}
<span class="status-error">✗ Fehler</span>
{% else %}
<span style="color:var(--text-muted);">{{ row.status }}</span>
{% endif %}
</td>
<td onclick="event.stopPropagation()">
<form method="POST" action="/emails/sent/{{ row.id }}/delete"
onsubmit="return confirm('Eintrag löschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen"></button>
</form>
</td>
</tr>
<!-- Expandable body row -->
<tr id="body-{{ row.id }}" style="display:none;">
<td colspan="6" style="background:var(--bg-elevated);padding:1rem;">
<pre style="white-space:pre-wrap;font-size:.82rem;margin:0;color:var(--text-primary);">{{ row.body or '(kein Inhalt)' }}</pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div><!-- /.tab-content -->
{% endblock %}
{% block scripts %}
<script>
function toggleBody(id) {
const row = document.getElementById('body-' + id);
if (row) row.style.display = row.style.display === 'none' ? '' : 'none';
}
</script>
{% endblock %} {% endblock %}

View file

@ -60,14 +60,19 @@
{% else %} {% else %}
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for mail in emails %} {% for mail in emails %}
<li class="list-group-item list-group-item-action" <li class="list-group-item list-group-item-action d-flex align-items-start gap-2" style="cursor:pointer;">
style="cursor:pointer;" onclick="viewEmail('{{ mail.id }}', '{{ mail.subject|e }}', '{{ mail.from|e }}')"> <div class="flex-grow-1" onclick="viewEmail('{{ mail.id }}', '{{ mail.subject|e }}', '{{ mail.from|e }}')">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<strong style="font-size:.875rem;">{{ mail.subject }}</strong> <strong style="font-size:.875rem;">{{ mail.subject }}</strong>
<small style="color:var(--text-muted);white-space:nowrap;margin-left:.5rem;">{{ mail.date[:10] }}</small> <small style="color:var(--text-muted);white-space:nowrap;margin-left:.5rem;">{{ mail.date[:10] }}</small>
</div>
<div style="font-size:.8rem;color:var(--text-muted);">{{ mail.from }}</div>
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.2rem;">{{ mail.preview }}</div>
</div> </div>
<div style="font-size:.8rem;color:var(--text-muted);">{{ mail.from }}</div> <form method="POST" action="/emails/inbox/{{ mail.id }}/delete"
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.2rem;">{{ mail.preview }}</div> onsubmit="return confirm('Email aus Posteingang löschen?')" style="flex-shrink:0;">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen"></button>
</form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>