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:
parent
99df910497
commit
f6ad727bf0
3 changed files with 340 additions and 165 deletions
238
app.py
238
app.py
|
|
@ -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
|
|
||||||
con = sqlite3.connect(EMAIL_JOURNAL_DB)
|
|
||||||
member = con.execute(
|
|
||||||
"SELECT name, role, email, responsibilities FROM team_members WHERE LOWER(email) = ?",
|
|
||||||
(email_clean.lower(),)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if member:
|
|
||||||
# Update je nach Field
|
|
||||||
updates = {
|
|
||||||
'name': member[0],
|
|
||||||
'role': member[1],
|
|
||||||
'email': member[2],
|
|
||||||
'responsibilities': member[3]
|
|
||||||
}
|
}
|
||||||
|
for block in re.findall(r'@UPDATE_TEAM_MEMBER\s*\n(.*?)@END', response_text, re.DOTALL):
|
||||||
if field_clean in updates:
|
lines = [l.strip() for l in block.strip().splitlines() if l.strip()]
|
||||||
updates[field_clean] = value_clean
|
identifier = None
|
||||||
success = update_team_member(
|
kwargs = {}
|
||||||
email_clean,
|
for line in lines:
|
||||||
updates['name'],
|
if ':' not in line:
|
||||||
updates['role'],
|
continue
|
||||||
updates['responsibilities']
|
key, _, val = line.partition(':')
|
||||||
)
|
key_norm = key.strip().lower().replace(' ', '_')
|
||||||
|
val_clean = val.strip()
|
||||||
|
if key_norm == 'identifier':
|
||||||
|
identifier = val_clean
|
||||||
|
elif key_norm in ALLOWED_UPDATE_FIELDS:
|
||||||
|
kwargs[ALLOWED_UPDATE_FIELDS[key_norm]] = val_clean
|
||||||
|
if not identifier:
|
||||||
|
logger.warning("[AgentCmd] @UPDATE_TEAM_MEMBER ohne Identifier ignoriert")
|
||||||
|
continue
|
||||||
|
if not kwargs:
|
||||||
|
logger.warning(f"[AgentCmd] @UPDATE_TEAM_MEMBER für '{identifier}' ohne Felder ignoriert")
|
||||||
|
continue
|
||||||
|
success = update_team_member(identifier, **kwargs)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"[AgentCmd] Team-Member aktualisiert: {email_clean} - {field_clean}")
|
logger.info(f"[AgentCmd] Team-Member aktualisiert: {identifier} - {list(kwargs.keys())}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"[AgentCmd] Update fehlgeschlagen für {email_clean}")
|
logger.error(f"[AgentCmd] Update fehlgeschlagen für '{identifier}'")
|
||||||
else:
|
|
||||||
logger.warning(f"[AgentCmd] Unbekanntes Field: {field_clean}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"[AgentCmd] Team-Member nicht gefunden: {email_clean}")
|
|
||||||
|
|
||||||
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'
|
||||||
|
|
@ -1397,8 +1404,28 @@ def send_email(to_address, subject, body):
|
||||||
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 loop and loop.is_running():
|
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
telegram_app.bot.send_message(chat_id=chat_id, text=message),
|
|
||||||
loop
|
|
||||||
)
|
)
|
||||||
future.result(timeout=30)
|
if r.ok:
|
||||||
else:
|
logging.info(f"[Telegram] Nachricht an {chat_id} gesendet.")
|
||||||
# Letzter Fallback: eigene Loop (kein Telegram-Bot läuft)
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
loop.run_until_complete(telegram_app.bot.send_message(chat_id=chat_id, text=message))
|
|
||||||
loop.close()
|
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
logging.error(f"[Telegram] API Fehler: {r.status_code} {r.text}")
|
||||||
|
return False
|
||||||
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'])
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,37 @@
|
||||||
{% 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>
|
||||||
<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>
|
<button class="btn btn-secondary btn-sm" onclick="location.reload()">Aktualisieren</button>
|
||||||
</div>
|
</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 |
|
<ul class="nav nav-tabs mb-3" id="emailLogTabs">
|
||||||
<span class="status-skipped me-3">— skipped</span> — Nicht auf Whitelist |
|
<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">
|
||||||
|
|
||||||
|
<!-- ── TAB: Posteingang / Journal ── -->
|
||||||
|
<div class="tab-pane fade show active" id="tab-inbox">
|
||||||
|
{% if not journal_rows %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body text-center py-5" style="color:var(--text-muted);">
|
<div class="card-body text-center py-5" style="color:var(--text-muted);">
|
||||||
<p style="font-size:2rem;">📭</p>
|
<p style="font-size:2rem;">📭</p>
|
||||||
<p>Noch keine Emails verarbeitet.<br><small>Der Poller prüft alle 2 Minuten den Posteingang.</small></p>
|
<p>Keine Journal-Einträge vorhanden.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -33,46 +44,47 @@
|
||||||
<table class="table table-hover log-table mb-0">
|
<table class="table table-hover log-table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Zeitstempel</th>
|
<th>Empfangen</th>
|
||||||
<th>Von</th>
|
<th>Von</th>
|
||||||
<th>Betreff</th>
|
<th>Betreff</th>
|
||||||
<th>Agent</th>
|
<th>Agent</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Antwort-Vorschau</th>
|
<th style="width:60px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in log_entries %}
|
{% for row in journal_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap;"><small style="color:var(--text-muted);">{{ entry.timestamp }}</small></td>
|
<td style="white-space:nowrap;">
|
||||||
<td><small>{{ entry.from }}</small></td>
|
<small style="color:var(--text-muted);">{{ row.received_at[:16] if row.received_at else '—' }}</small>
|
||||||
<td>{{ entry.subject }}</td>
|
</td>
|
||||||
|
<td><small>{{ row.sender or '—' }}</small></td>
|
||||||
|
<td>{{ row.subject or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.agent %}
|
{% if row.agent_key %}
|
||||||
<span class="badge bg-primary badge-agent">{{ entry.agent }}</span>
|
<span class="badge bg-primary badge-agent">{{ row.agent_key }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:var(--text-muted);">—</span>
|
<span style="color:var(--text-muted);">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.status == 'replied' or entry.status == 'completed' %}
|
{% if row.status == 'completed' %}
|
||||||
<span class="status-replied">✓ replied</span>
|
<span class="status-replied">✓ completed</span>
|
||||||
{% elif entry.status == 'skipped' %}
|
{% elif row.status == 'skipped' %}
|
||||||
<span class="status-skipped">— skipped</span>
|
<span class="status-skipped">— skipped</span>
|
||||||
{% elif entry.status == 'error' %}
|
{% elif row.status == 'error' %}
|
||||||
<span class="status-error">✗ error</span>
|
<span class="status-error">✗ error</span>
|
||||||
{% elif entry.status == 'queued' %}
|
{% elif row.status == 'queued' %}
|
||||||
<span style="color:var(--warning);">⏳ queued</span>
|
<span style="color:var(--warning);">⏳ queued</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:var(--text-muted);">{{ entry.status }}</span>
|
<span style="color:var(--text-muted);">{{ row.status }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.response_preview %}
|
<form method="POST" action="/emails/journal/{{ row.message_id | urlencode }}/delete"
|
||||||
<div class="response-preview">{{ entry.response_preview }}</div>
|
onsubmit="return confirm('Journal-Eintrag löschen?')">
|
||||||
{% else %}
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Löschen">✕</button>
|
||||||
<small style="color:var(--text-muted);">—</small>
|
</form>
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -82,9 +94,87 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-3" style="font-size:.78rem;color:var(--text-muted);">
|
|
||||||
Whitelist: <strong>eric.fischer@signtime.media</strong>, <strong>p.dyderski@live.at</strong>,
|
|
||||||
<strong>georg.tschare@gmail.com</strong>, <strong>*@diversityball.at</strong> · Max. 50 Einträge
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: Gesendet ── -->
|
||||||
|
<div class="tab-pane fade" id="tab-sent">
|
||||||
|
{% if not sent_rows %}
|
||||||
|
<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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<div style="font-size:.8rem;color:var(--text-muted);">{{ mail.from }}</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 style="font-size:.78rem;color:var(--text-muted);margin-top:.2rem;">{{ mail.preview }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/emails/inbox/{{ mail.id }}/delete"
|
||||||
|
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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue