Compare commits

...

7 Commits

Author SHA1 Message Date
bfc2283e6f chore: auto-commit from dashboard 2026-04-22 11:05:14 +00:00
51af0918a4 feat(email): send attachments as WhatsApp documents, fix forward sender
- Add /send-document endpoint to WhatsApp bridge (base64 document send)
- save_email_as_note() now saves attachment files to disk alongside note
- email_digest: extract original sender for Fwd: emails so header shows
  the real author, not the forwarder; send attachment files after summary
- email_forward: send attachment files as documents after text parts
- Add extract_original_sender() and save_email_attachment_files() helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:50:40 +00:00
417de65069 fix(email): use original sender for forwarded emails in digest
Digest was attributing forwarded emails to the person who forwarded
them. Now Claude is instructed to identify the original sender from
the forwarded headers and ignore the forwarder entirely. Also drops
pleasantries/apologies from the summary — facts only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:48:11 +00:00
c2455e6245 improve(email): switch digest prompt to factual briefing style
Previous prompt produced narrative, personal-tone summaries. New prompt
enforces third-person, journalistic style: who sent what to whom first,
then concrete facts, dates, and actions — no interpretation or filler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:43:58 +00:00
56f6c0df01 feat(email): show attachments in digest and forward commands
Add get_email_attachments() helper that extracts filenames from MIME
parts. Email notes now include an Atașamente section; forwarded emails
show attachment names in the WhatsApp header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:41:21 +00:00
eb693a2e71 improve(email): rewrite digest prompt for context-aware summaries
Rigid bullet schema worked for event emails but stripped all
narrative context from argumentative/organizational messages.
New prompt adapts structure to email type and prioritizes
completeness over brevity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:33:34 +00:00
30678e6abf fix(email): send WhatsApp notification when no new emails found
Previously digest and forward commands silently exited when inbox
was empty, leaving the user with no feedback after the initial
"processing..." confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:29:58 +00:00
8 changed files with 300 additions and 84 deletions

View File

@@ -113,7 +113,7 @@ async function startConnection() {
// --- Express API ---
const app = express();
app.use(express.json());
app.use(express.json({ limit: '50mb' }));
app.get('/status', (_req, res) => {
res.json({
@@ -187,6 +187,29 @@ app.post('/send', async (req, res) => {
}
});
app.post('/send-document', async (req, res) => {
const { to, filename, mimetype, data_base64, caption } = req.body || {};
if (!to || !filename || !data_base64) {
return res.status(400).json({ ok: false, error: 'missing "to", "filename", or "data_base64"' });
}
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const buffer = Buffer.from(data_base64, 'base64');
const result = await sock.sendMessage(to, {
document: buffer,
fileName: filename,
mimetype: mimetype || 'application/octet-stream',
caption: caption || '',
});
res.json({ ok: true, id: result.key.id });
} catch (err) {
console.error('[whatsapp] Send document failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/react', async (req, res) => {
const { to, id, emoji, fromMe, participant } = req.body || {};

View File

@@ -94,6 +94,9 @@
"Bash(scp *10.0.20.*)",
"Bash(rsync *10.0.20.*)"
],
"discord": {
"email_webhook_url": "https://discord.com/api/webhooks/1496421990846697583/OM8z1eBsJC6-UB9-Zi5RkHP23NNv9UrEznRMx4Y3wSWOFmLazPoi-8_iEKMp0Qgsqr-m"
},
"ollama": {
"url": "http://10.0.20.161:11434"
},

View File

@@ -37,9 +37,9 @@
"report_on": "changes",
"timeout": 120,
"enabled": true,
"last_run": null,
"last_status": null,
"next_run": null
"last_run": "2026-04-22T10:00:00.001000+00:00",
"last_status": "ok",
"next_run": "2026-04-22T16:00:00+00:00"
},
{
"name": "security-audit-daily",
@@ -53,9 +53,9 @@
"report_on": "changes",
"timeout": 180,
"enabled": true,
"last_run": null,
"last_status": null,
"next_run": null
"last_run": "2026-04-22T03:00:00.003515+00:00",
"last_status": "error",
"next_run": "2026-04-23T03:00:00+00:00"
},
{
"name": "kb-index-refresh",
@@ -69,9 +69,9 @@
"report_on": "never",
"timeout": 120,
"enabled": true,
"last_run": null,
"last_status": null,
"next_run": null
"last_run": "2026-04-22T03:30:00.003672+00:00",
"last_status": "ok",
"next_run": "2026-04-23T03:30:00+00:00"
},
{
"name": "archive-tasks-daily",
@@ -85,9 +85,9 @@
"report_on": "changes",
"timeout": 60,
"enabled": true,
"last_run": null,
"last_status": null,
"next_run": null
"last_run": "2026-04-22T03:00:00.003110+00:00",
"last_status": "ok",
"next_run": "2026-04-23T03:00:00+00:00"
},
{
"name": "backup-config",
@@ -101,9 +101,9 @@
"report_on": "never",
"timeout": 120,
"enabled": true,
"last_run": null,
"last_status": null,
"next_run": null
"last_run": "2026-04-22T02:00:00.001806+00:00",
"last_status": "ok",
"next_run": "2026-04-23T02:00:00+00:00"
},
{
"name": "insights-extract",
@@ -269,8 +269,8 @@
"prompt": "Heartbeat check. Rulează src/heartbeat.py printr-un scurt raport de status.\nDacă nu e nimic de raportat (email=0, calendar nu are evenimente <2h, kb ok), răspunde doar cu HEARTBEAT_OK și oprește-te — nu trimite mesaj.\nDacă e ceva: raport scurt pe Discord #echo-work.",
"allowed_tools": [],
"enabled": true,
"last_run": "2026-04-21T13:00:00.002339+00:00",
"last_run": "2026-04-22T11:00:00.001844+00:00",
"last_status": "ok",
"next_run": "2026-04-21T15:00:00+00:00"
"next_run": "2026-04-22T13:00:00+00:00"
}
]

View File

@@ -10,33 +10,10 @@
"time": "30 Jan 2026, 22:00"
},
"anaf": {
"ok": false,
"status": "MODIFICĂRI",
"message": "3 modificări detectate",
"lastCheck": "21 Apr 2026, 10:04",
"changesCount": 3,
"changes": [
{
"name": "Declarația 100 - Obligații de plată la bugetul de stat",
"url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/100.html",
"summary": [
"Soft J: 22.01.2026 → 07.04.2026"
]
},
{
"name": "Bilanț 31.12.2025 (S1002-S1005)",
"url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/situatiifinanciare/2025/1002_5_2025.html",
"summary": [
"Pagina s-a modificat"
]
},
{
"name": "Situații financiare anuale 2025",
"url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/situatiifinanciare/2025/1030_2025.html",
"summary": [
"Pagina s-a modificat"
]
}
]
"ok": true,
"status": "OK",
"message": "Nicio modificare detectată",
"lastCheck": "22 Apr 2026, 10:00",
"changesCount": 0
}
}

View File

@@ -916,3 +916,29 @@
[2026-04-21 10:04:32] HASH CHANGED in SIT_FIN_AN_2025 (no version changes detected)
[2026-04-21 10:04:32] OK: DESCARCARE_DECLARATII
[2026-04-21 10:04:32] === Monitor complete ===
[2026-04-21 16:00:00] === Starting ANAF monitor v2.1 ===
[2026-04-21 16:00:00] OK: D100
[2026-04-21 16:00:00] OK: D101
[2026-04-21 16:00:00] OK: D300
[2026-04-21 16:00:00] OK: D390
[2026-04-21 16:00:00] OK: D394
[2026-04-21 16:00:00] OK: D205
[2026-04-21 16:00:01] OK: D406
[2026-04-21 16:00:01] OK: BILANT_2025
[2026-04-21 16:00:01] OK: SIT_FIN_SEM_2025
[2026-04-21 16:00:01] OK: SIT_FIN_AN_2025
[2026-04-21 16:00:01] OK: DESCARCARE_DECLARATII
[2026-04-21 16:00:01] === Monitor complete ===
[2026-04-22 10:00:00] === Starting ANAF monitor v2.1 ===
[2026-04-22 10:00:00] OK: D100
[2026-04-22 10:00:00] OK: D101
[2026-04-22 10:00:00] OK: D300
[2026-04-22 10:00:03] OK: D390
[2026-04-22 10:00:03] OK: D394
[2026-04-22 10:00:03] OK: D205
[2026-04-22 10:00:03] OK: D406
[2026-04-22 10:00:04] OK: BILANT_2025
[2026-04-22 10:00:04] OK: SIT_FIN_SEM_2025
[2026-04-22 10:00:04] OK: SIT_FIN_AN_2025
[2026-04-22 10:00:04] OK: DESCARCARE_DECLARATII
[2026-04-22 10:00:04] === Monitor complete ===

View File

@@ -8,6 +8,7 @@ Usage:
"""
import sys
import base64
import subprocess
import requests
from pathlib import Path
@@ -15,7 +16,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.email_process import save_unread_emails
from tools.email_process import save_unread_emails, extract_original_sender
from src.config import Config
BRIDGE_URL = "http://127.0.0.1:8098"
@@ -35,35 +36,28 @@ def generate_summary(filepath: str, subject: str, from_full: str, date: str) ->
except Exception as e:
return f"[Eroare la citirea fișierului: {e}]"
prompt = f"""Mai jos este conținutul unui email. Generează un rezumat pentru WhatsApp.
display_from = extract_original_sender(subject, email_content, from_full)
prompt = f"""Mai jos este conținutul unui email. Scrie un rezumat factual pentru WhatsApp.
EMAIL:
{email_content}
Format obligatoriu (plain text, fără markdown):
SUBIECT: {subject}
De la: {from_full}
Primit: {date}
---
[Rezumat compact dar complet — include TOATE detaliile importante:
- ce se întâmplă / despre ce e vorba
- cine organizează / cine trimite
- când (date, deadline-uri, perioade)
- unde (locație dacă există)
- ce trebuie să facă cititorul (acțiune clară)
- condiții de participare (vârstă, criterii etc.) dacă există
- ce este inclus/oferit dacă e relevant
Fiecare punct pe linie separată. Fără redundanță, fără filler.]
LINKURI:
[Listează TOATE linkurile acționabile: formulare, înscrieri, site-uri, documente, social media]
[Format: "Descriere scurtă: https://..." — câte un link per linie]
[Dacă nu există linkuri, omite secțiunea]
[Semnătura expeditorului din email pe ultima linie după —]
Fără emoji dacă nu sunt în original. Răspunde DOAR cu rezumatul, nimic altceva."""
Instrucțiuni:
- Începe cu header-ul fix (fără modificări):
SUBIECT: {subject}
De la: {display_from}
Primit: {date}
---
- Ignoră complet orice persoană care a forwardat emailul. Nu o menționă în rezumat.
- Scrie rezumatul în stil briefing: factual, clar, persoana a 3-a.
* Prima propoziție: cine a trimis mesajul original, ce, cui.
* Ce conține mesajul — concret și direct. Omite politețuri și amabilități; include doar faptele.
* Dacă există termene, date, locuri sau acțiuni cerute — menționează-le explicit.
* Dacă există linkuri acționabile (formulare, documente), adaugă o secțiune LINKURI la final.
- Nu adăuga secțiuni goale sau care nu se aplică emailului.
- Plain text, fără markdown. Fără emoji.
- Răspunde DOAR cu rezumatul, nimic altceva."""
result = subprocess.run(
["claude", "--print", prompt],
@@ -94,16 +88,55 @@ def send_whatsapp(to: str, text: str) -> bool:
return False
def send_discord_webhook(text: str) -> bool:
"""Trimite mesaj pe Discord via webhook (max 2000 chars per mesaj)."""
config = Config(PROJECT_ROOT / "config.json")
url = config.get("discord.email_webhook_url", "")
if not url:
return False
try:
chunks = [text[i:i+2000] for i in range(0, len(text), 2000)]
for chunk in chunks:
resp = requests.post(url, json={"content": chunk}, timeout=15)
if resp.status_code not in (200, 204):
print(f"[discord webhook] status {resp.status_code}", file=sys.stderr)
return False
return True
except Exception as e:
print(f"[discord webhook eroare] {e}", file=sys.stderr)
return False
def send_whatsapp_document(to: str, filepath: str) -> bool:
"""Trimite un fișier ca document WhatsApp prin bridge."""
try:
path = Path(filepath)
data_b64 = base64.b64encode(path.read_bytes()).decode()
import mimetypes
mimetype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
resp = requests.post(
f"{BRIDGE_URL}/send-document",
json={"to": to, "filename": path.name, "mimetype": mimetype, "data_base64": data_b64},
timeout=30,
)
return resp.json().get("ok", False)
except Exception as e:
print(f"[eroare send-document] {e}", file=sys.stderr)
return False
def run_digest():
print("📬 Verific emailuri necitite...")
saved = save_unread_emails()
owner_jid = get_owner_jid()
if not saved:
print("Niciun email nou de procesat.")
if not DRY_RUN:
send_whatsapp(owner_jid, "📭 Nu sunt emailuri noi.")
return
owner_jid = get_owner_jid()
for result in saved:
if not result.get("ok"):
print(f"⚠️ Sărit: {result.get('error')}")
@@ -113,6 +146,7 @@ def run_digest():
subject = result["subject"]
from_full = result.get("from_full", result.get("from", ""))
date = result.get("date", "")
attachment_paths = result.get("attachment_paths", [])
print(f"📧 Procesez: {subject}")
summary = generate_summary(filepath, subject, from_full, date)
@@ -120,6 +154,8 @@ def run_digest():
if DRY_RUN:
print("\n--- REZUMAT (dry-run) ---")
print(summary)
if attachment_paths:
print(f"Atașamente: {attachment_paths}")
print("------------------------\n")
else:
ok = send_whatsapp(owner_jid, summary)
@@ -128,6 +164,20 @@ def run_digest():
else:
print(f"❌ Trimitere eșuată: {subject}")
ok_dc = send_discord_webhook(summary)
if ok_dc:
print(f"✅ Trimis pe Discord: {subject}")
else:
print(f"❌ Discord eșuat: {subject}")
for att_path in attachment_paths:
ok_att = send_whatsapp_document(owner_jid, att_path)
name = Path(att_path).name
if ok_att:
print(f"✅ Atașament trimis: {name}")
else:
print(f"❌ Atașament eșuat: {name}")
if __name__ == "__main__":
run_digest()

View File

@@ -9,6 +9,8 @@ Usage:
import sys
import re
import base64
import mimetypes
import requests
from pathlib import Path
@@ -17,7 +19,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
from tools.email_process import (
IMAP_SERVER, IMAP_PORT, IMAP_USER, IMAP_PASS,
WHITELIST, decode_mime_header, extract_sender_email, get_email_body
WHITELIST, decode_mime_header, extract_sender_email, get_email_body,
get_email_attachments
)
from src.config import Config
import imaplib
@@ -46,7 +49,7 @@ def extract_original_sender(body: str, from_full: str) -> tuple[str, str]:
return from_full, ""
def format_for_whatsapp(subject: str, from_full: str, date: str, body: str) -> str:
def format_for_whatsapp(subject: str, from_full: str, date: str, body: str, attachments: list = None) -> str:
"""Curăță corpul emailului și îl formatează pentru WhatsApp."""
# Extrage expeditorul original dacă e forward
original_from, original_date = extract_original_sender(body, from_full)
@@ -70,7 +73,11 @@ def format_for_whatsapp(subject: str, from_full: str, date: str, body: str) -> s
body = '\n'.join(line.rstrip() for line in body.splitlines())
body = body.strip()
header = f"*{subject}*\nDe la: {display_from}\nPrimit: {display_date}\n---\n"
att_line = ""
if attachments:
att_line = "\nAtașamente: " + ", ".join(attachments) + "\n"
header = f"*{subject}*\nDe la: {display_from}\nPrimit: {display_date}{att_line}\n---\n"
full = header + body
if len(full) <= MAX_WA_LENGTH:
@@ -110,6 +117,40 @@ def send_whatsapp(to: str, text: str) -> bool:
return False
def send_discord_webhook(text: str) -> bool:
"""Trimite mesaj pe Discord via webhook (max 2000 chars per mesaj)."""
config = Config(PROJECT_ROOT / "config.json")
url = config.get("discord.email_webhook_url", "")
if not url:
return False
try:
chunks = [text[i:i+2000] for i in range(0, len(text), 2000)]
for chunk in chunks:
resp = requests.post(url, json={"content": chunk}, timeout=15)
if resp.status_code not in (200, 204):
print(f"[discord webhook] status {resp.status_code}", file=sys.stderr)
return False
return True
except Exception as e:
print(f"[discord webhook eroare] {e}", file=sys.stderr)
return False
def send_whatsapp_document(to: str, filename: str, data: bytes) -> bool:
try:
mimetype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
resp = requests.post(
f"{BRIDGE_URL}/send-document",
json={"to": to, "filename": filename, "mimetype": mimetype,
"data_base64": base64.b64encode(data).decode()},
timeout=30,
)
return resp.json().get("ok", False)
except Exception as e:
print(f"[eroare send-document] {e}", file=sys.stderr)
return False
def fetch_unread_emails():
"""Preia emailurile necitite din inbox fără a le salva sau marca ca citite."""
mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
@@ -133,11 +174,24 @@ def fetch_unread_emails():
if sender_email not in WHITELIST:
continue
# Extract attachment data (name → bytes)
att_data = {}
if msg.is_multipart():
for part in msg.walk():
fname = part.get_filename()
if fname:
fname = decode_mime_header(fname)
payload = part.get_payload(decode=True)
if payload:
att_data[fname] = payload
results.append({
'subject': decode_mime_header(msg['Subject']),
'from_full': from_addr,
'date': msg['Date'],
'body': get_email_body(msg),
'attachments': list(att_data.keys()),
'attachment_data': att_data,
})
mail.logout()
@@ -148,22 +202,26 @@ def run_forward():
print("Verific emailuri necitite...")
emails = fetch_unread_emails()
owner_jid = get_owner_jid()
if not emails:
print("Niciun email nou de procesat.")
if not DRY_RUN:
send_whatsapp(owner_jid, "📭 Nu sunt emailuri noi.")
return
owner_jid = get_owner_jid()
for em in emails:
subject = em['subject']
print(f"Trimit: {subject}")
parts = format_for_whatsapp(subject, em['from_full'], em['date'], em['body'])
parts = format_for_whatsapp(subject, em['from_full'], em['date'], em['body'], em.get('attachments', []))
if DRY_RUN:
for i, part in enumerate(parts):
print(f"\n--- FORWARD {i+1}/{len(parts)} (dry-run) ---")
print(part)
print("------------------------\n")
if em.get('attachment_data'):
print(f"Atașamente: {list(em['attachment_data'].keys())}")
else:
for part in parts:
ok = send_whatsapp(owner_jid, part)
@@ -173,6 +231,20 @@ def run_forward():
else:
print(f"Trimis pe WhatsApp ({len(parts)} mesaje): {subject}")
full_text = "\n".join(parts)
ok_dc = send_discord_webhook(full_text)
if ok_dc:
print(f"Trimis pe Discord: {subject}")
else:
print(f"Discord eșuat: {subject}")
for fname, fdata in em.get('attachment_data', {}).items():
ok_att = send_whatsapp_document(owner_jid, fname, fdata)
if ok_att:
print(f"Atașament trimis: {fname}")
else:
print(f"Atașament eșuat: {fname}")
if __name__ == "__main__":
run_forward()

View File

@@ -80,6 +80,59 @@ def get_email_body(msg):
body = payload.decode(charset, errors='replace')
return body.strip()
def get_email_attachments(msg) -> list:
"""Extract list of attachment filenames from email MIME parts."""
attachments = []
if not msg.is_multipart():
return attachments
for part in msg.walk():
filename = part.get_filename()
if filename:
attachments.append(decode_mime_header(filename))
elif part.get('Content-Disposition', '').lower().startswith('attachment'):
attachments.append(f"[{part.get_content_type()}]")
return attachments
def save_email_attachment_files(msg, dest_dir: Path) -> list:
"""Save attachment files from email to dest_dir. Returns list of saved file paths."""
saved = []
if not msg.is_multipart():
return saved
dest_dir.mkdir(parents=True, exist_ok=True)
for part in msg.walk():
filename = part.get_filename()
if not filename:
continue
filename = decode_mime_header(filename)
payload = part.get_payload(decode=True)
if payload is None:
continue
dest = dest_dir / filename
# Avoid overwriting — append counter if needed
counter = 1
while dest.exists():
stem, suffix = Path(filename).stem, Path(filename).suffix
dest = dest_dir / f"{stem}_{counter}{suffix}"
counter += 1
dest.write_bytes(payload)
saved.append(dest)
return saved
def extract_original_sender(subject: str, body_content: str, from_full: str) -> str:
"""If email is a forward, extract original sender from body."""
if not re.match(r'^(fwd?|fw)\s*[:\s]', subject, re.IGNORECASE):
return from_full
match = re.search(
r'(?:De la|From):\s*(.+?)(?:\n|$)',
body_content, re.IGNORECASE | re.MULTILINE
)
if match:
candidate = match.group(1).strip()
# Skip blank or markdown artifacts
if candidate and not candidate.startswith('**') and '@' in candidate or len(candidate) > 3:
return candidate
return from_full
def extract_sender_email(from_header: str) -> str:
"""Extract just the email address from From header"""
match = re.search(r'<([^>]+)>', from_header)
@@ -137,6 +190,7 @@ def save_email_as_note(eid: str) -> dict:
subject = decode_mime_header(msg['Subject'])
date_str = msg['Date']
body = get_email_body(msg)
attachments = get_email_attachments(msg)
# Check whitelist
if sender_email not in WHITELIST:
@@ -162,6 +216,12 @@ def save_email_as_note(eid: str) -> dict:
filename = f"{date_prefix}_{slug}.md"
filepath = KB_PATH / filename
# Build attachments section
attachments_section = ""
if attachments:
att_list = "\n".join(f"- {a}" for a in attachments)
attachments_section = f"\n## Atașamente\n{att_list}\n"
# Create markdown note
content = f"""# {subject}
@@ -172,7 +232,7 @@ def save_email_as_note(eid: str) -> dict:
---
{body}
{attachments_section}
---
## TL;DR
@@ -185,6 +245,10 @@ def save_email_as_note(eid: str) -> dict:
KB_PATH.mkdir(parents=True, exist_ok=True)
filepath.write_text(content, encoding='utf-8')
# Save attachment files next to the note
att_dir = KB_PATH / f"{date_prefix}_{slug}_attachments"
attachment_paths = save_email_attachment_files(msg, att_dir)
# Mark as seen
mail.store(eid.encode(), '+FLAGS', '\\Seen')
mail.logout()
@@ -196,6 +260,7 @@ def save_email_as_note(eid: str) -> dict:
'from': sender_email,
'from_full': from_addr,
'date': date_str,
'attachment_paths': [str(p) for p in attachment_paths],
}
def save_unread_emails():