|
|
|
@@ -13,20 +13,43 @@ from email.utils import parseaddr
|
|
|
|
|
from email.mime.text import MIMEText
|
|
|
|
|
from email.header import decode_header, make_header
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
# ==========================
|
|
|
|
|
# CONFIG (compila qui)
|
|
|
|
|
# CONFIG da config.json (accanto allo script/EXE)
|
|
|
|
|
# ==========================
|
|
|
|
|
IMAP_SERVER = "mail.yogasoul.it"
|
|
|
|
|
EMAIL_ADDRESS = "info@yogasoul.it"
|
|
|
|
|
EMAIL_PASSWORD = "!Testolina88" # <— METTI LA PASSWORD QUI
|
|
|
|
|
OPENAI_API_KEY = "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" # <— METTI LA OPENAI KEY QUI
|
|
|
|
|
KB_PATH = "yogasoul_knowledge_base.json"
|
|
|
|
|
OPENAI_MODEL = "gpt-3.5-turbo"
|
|
|
|
|
PREFERRED_DRAFT_FOLDER = "BozzaRisposte" # se non trova Drafts, crea INBOX.<questa>
|
|
|
|
|
MARK_AS_SEEN = True # False per lasciare le email non lette
|
|
|
|
|
THROTTLE_SECONDS = 0 # ritardo tra email (0 = nessuno)
|
|
|
|
|
MAX_TO_PROCESS = 5 # ⬅️ NUOVO: processa al massimo N email UNSEEN (ultime)
|
|
|
|
|
def app_dir() -> Path:
|
|
|
|
|
# se "frozen" (PyInstaller), usa la cartella dell'eseguibile
|
|
|
|
|
return Path(sys.executable).parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent
|
|
|
|
|
|
|
|
|
|
CONFIG_PATH = app_dir() / "config.json"
|
|
|
|
|
|
|
|
|
|
def load_config():
|
|
|
|
|
try:
|
|
|
|
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
|
|
|
cfg = json.load(f)
|
|
|
|
|
print(f"[CONFIG] Caricato: {CONFIG_PATH}")
|
|
|
|
|
return cfg
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[ERRORE] Impossibile leggere {CONFIG_PATH}: {e}")
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
CFG = load_config()
|
|
|
|
|
|
|
|
|
|
# valori letti dal config (con default sensati)
|
|
|
|
|
IMAP_SERVER = CFG.get("imap_server", "mail.yogasoul.it")
|
|
|
|
|
EMAIL_ADDRESS = CFG.get("email_address", "")
|
|
|
|
|
EMAIL_PASSWORD = CFG.get("email_password", "")
|
|
|
|
|
OPENAI_API_KEY = CFG.get("openai_api_key", "")
|
|
|
|
|
OPENAI_MODEL = CFG.get("openai_model", "gpt-3.5-turbo")
|
|
|
|
|
PREFERRED_DRAFT_FOLDER = CFG.get("preferred_draft_folder", "BozzaRisposte")
|
|
|
|
|
MARK_AS_SEEN = bool(CFG.get("mark_as_seen", True))
|
|
|
|
|
THROTTLE_SECONDS = float(CFG.get("throttle_seconds", 0) or 0)
|
|
|
|
|
MAX_TO_PROCESS = int(CFG.get("max_to_process", 5) or 5)
|
|
|
|
|
# Percorsi esterni (accanto all'eseguibile)
|
|
|
|
|
KB_PATH = str(app_dir() / CFG.get("kb_path", "yogasoul_knowledge_base.json"))
|
|
|
|
|
PROMPT_PATH = str(app_dir() / CFG.get("prompt_path", "prompt_template.txt"))
|
|
|
|
|
|
|
|
|
|
# ==========================
|
|
|
|
|
# UTILS
|
|
|
|
@@ -72,7 +95,6 @@ def _ensure_html_blocks(s):
|
|
|
|
|
body = "<p>{}</p>".format(html_lib.escape(s).replace("\n", "<br>"))
|
|
|
|
|
return "<!doctype html><html><body>{}</body></html>".format(body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_quoted_original(body_text):
|
|
|
|
|
"""Crea il blocco citato del messaggio originale, safe-escaped."""
|
|
|
|
|
if not body_text:
|
|
|
|
@@ -84,6 +106,14 @@ def _make_quoted_original(body_text):
|
|
|
|
|
f"<blockquote style='margin:0 0 0 1em; padding-left:1em; border-left:3px solid #ddd'>{escaped}</blockquote>"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _inject_before_body_end(html_src: str, addition: str) -> str:
|
|
|
|
|
"""Inserisce 'addition' prima di </body> in modo case-insensitive; se manca </body>, appende."""
|
|
|
|
|
m = re.search(r'</\s*body\s*>', html_src, flags=re.I)
|
|
|
|
|
if not m:
|
|
|
|
|
return html_src + addition
|
|
|
|
|
start = m.start()
|
|
|
|
|
return html_src[:start] + addition + html_src[start:]
|
|
|
|
|
|
|
|
|
|
def load_knowledge_base(path=KB_PATH):
|
|
|
|
|
if not os.path.isfile(path):
|
|
|
|
|
print(f"[ATTENZIONE] KB non trovata: {path}. Proseguo senza.")
|
|
|
|
@@ -98,20 +128,39 @@ def load_knowledge_base(path=KB_PATH):
|
|
|
|
|
def assert_config():
|
|
|
|
|
problems = []
|
|
|
|
|
if not isinstance(IMAP_SERVER, str) or not IMAP_SERVER.strip():
|
|
|
|
|
problems.append("IMAP_SERVER mancante")
|
|
|
|
|
problems.append("imap_server mancante in config.json")
|
|
|
|
|
if not isinstance(EMAIL_ADDRESS, str) or not EMAIL_ADDRESS.strip():
|
|
|
|
|
problems.append("EMAIL_ADDRESS mancante")
|
|
|
|
|
if not isinstance(EMAIL_PASSWORD, str) or not EMAIL_PASSWORD.strip() or EMAIL_PASSWORD == "INSERISCI_LA_PASSWORD":
|
|
|
|
|
problems.append("EMAIL_PASSWORD non impostata")
|
|
|
|
|
if not isinstance(OPENAI_API_KEY, str) or not OPENAI_API_KEY.strip() or OPENAI_API_KEY == "INSERISCI_OPENAI_API_KEY":
|
|
|
|
|
problems.append("OPENAI_API_KEY non impostata")
|
|
|
|
|
problems.append("email_address mancante in config.json")
|
|
|
|
|
if not isinstance(EMAIL_PASSWORD, str) or not EMAIL_PASSWORD.strip():
|
|
|
|
|
problems.append("email_password mancante in config.json")
|
|
|
|
|
if not isinstance(OPENAI_API_KEY, str) or not OPENAI_API_KEY.strip():
|
|
|
|
|
problems.append("openai_api_key mancante in config.json")
|
|
|
|
|
if problems:
|
|
|
|
|
print("[CONFIG] Correggi questi parametri prima di proseguire:")
|
|
|
|
|
print("[CONFIG] Correggi config.json:")
|
|
|
|
|
for p in problems:
|
|
|
|
|
print(" -", p)
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# ==========================
|
|
|
|
|
# Prompt esterno
|
|
|
|
|
# ==========================
|
|
|
|
|
def load_prompt(path: str) -> str:
|
|
|
|
|
try:
|
|
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
|
|
|
print(f"[PROMPT] Caricato: {path}")
|
|
|
|
|
return f.read()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[ATTENZIONE] Prompt non trovato o illeggibile: {path} ({e})")
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def render_prompt(template: str, **vars_) -> str:
|
|
|
|
|
# Sostituzione semplice stile {{nome}}
|
|
|
|
|
out = template
|
|
|
|
|
for k, v in vars_.items():
|
|
|
|
|
out = out.replace(f"{{{{{k}}}}}", str(v))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
# ==========================
|
|
|
|
|
# IMAP helpers robusti
|
|
|
|
|
# ==========================
|
|
|
|
@@ -229,25 +278,33 @@ def generate_response(email_info, kb):
|
|
|
|
|
f"Corpo: {email_info.get('body_text','')}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
prompt = f"""
|
|
|
|
|
Sei Aurora, fondatrice di YogaSoul (www.yogasoul.it), stile zen e informale, diretto.
|
|
|
|
|
Rispondi in italiano, amichevole e rilassato, con emoticon yoga (🌿, 🧘♀️, 😊) senza esagerare.
|
|
|
|
|
Saluta con "Ciao {nickname}, bello sentirti!" e firma con "Namaste, Aurora - YogaSoul".
|
|
|
|
|
Usa la knowledge base per corsi, orari, prezzi, benefici, insegnanti.
|
|
|
|
|
Includi sempre il link_prenotazione specifico come <a href='link'>Iscriviti qui</a> per prenotazioni,
|
|
|
|
|
e il calendario <a href='https://yogasoul.it/wp-content/uploads/2025/08/Calendario-settembre-2025-2.jpg'>qui</a>.
|
|
|
|
|
Se non sai, scrivi: "Contattami per dettagli! 🧘♀️".
|
|
|
|
|
# Prompt da file esterno (con fallback interno)
|
|
|
|
|
template = load_prompt(PROMPT_PATH)
|
|
|
|
|
if not template:
|
|
|
|
|
template = (
|
|
|
|
|
"Sei Aurora, fondatrice di YogaSoul (www.yogasoul.it), stile zen e informale, diretto.\n"
|
|
|
|
|
"Rispondi in italiano, amichevole e rilassato, con emoticon yoga (🌿, 🧘♀️, 😊) senza esagerare.\n"
|
|
|
|
|
"Saluta con \"Ciao {{nickname}}, bello sentirti!\" e firma con \"Namaste, Aurora - YogaSoul\".\n"
|
|
|
|
|
"Usa la knowledge base per corsi, orari, prezzi, benefici, insegnanti.\n"
|
|
|
|
|
"Includi sempre il link_prenotazione specifico come <a href='link'>Iscriviti qui</a> per prenotazioni,\n"
|
|
|
|
|
"e il calendario <a href='https://yogasoul.it/wp-content/uploads/2025/08/Calendario-settembre-2025-2.jpg'>qui</a>.\n"
|
|
|
|
|
"Se non sai, scrivi: \"Contattami per dettagli! 🧘♀️\".\n\n"
|
|
|
|
|
"{{policy}}\n\n"
|
|
|
|
|
"Knowledge Base:\n"
|
|
|
|
|
"{{kb_json}}\n\n"
|
|
|
|
|
"Email ricevuta:\n"
|
|
|
|
|
"{{email_text}}\n\n"
|
|
|
|
|
"Scrivi la risposta in HTML pulito (usa <p>, <ul>/<li> se utile; niente CSS superfluo).\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
{policy}
|
|
|
|
|
prompt = render_prompt(
|
|
|
|
|
template,
|
|
|
|
|
nickname=nickname,
|
|
|
|
|
policy=policy,
|
|
|
|
|
kb_json=kb_json,
|
|
|
|
|
email_text=email_text,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Knowledge Base:
|
|
|
|
|
{kb_json}
|
|
|
|
|
|
|
|
|
|
Email ricevuta:
|
|
|
|
|
{email_text}
|
|
|
|
|
|
|
|
|
|
Scrivi la risposta in HTML pulito (usa <p>, <ul>/<li> se utile; niente CSS superfluo).
|
|
|
|
|
"""
|
|
|
|
|
resp = client.chat.completions.create(
|
|
|
|
|
model=OPENAI_MODEL,
|
|
|
|
|
messages=[{"role": "user", "content": prompt}],
|
|
|
|
@@ -283,7 +340,7 @@ def fetch_all_unseen(mail, limit=None):
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
if limit and limit > 0:
|
|
|
|
|
ids = ids[-limit:] # ⬅️ prendi SOLO le ultime N non lette
|
|
|
|
|
ids = ids[-limit:] # prendi SOLO le ultime N non lette
|
|
|
|
|
|
|
|
|
|
for seq_id in ids:
|
|
|
|
|
typ, msg_data = mail.fetch(seq_id, "(RFC822)")
|
|
|
|
@@ -335,9 +392,10 @@ def fetch_all_unseen(mail, limit=None):
|
|
|
|
|
|
|
|
|
|
def prepare_reply_mime(email_info, response_html):
|
|
|
|
|
"""Costruisce il MIME HTML della risposta con quote e thread headers."""
|
|
|
|
|
reply_body = (response_html.replace("</body>", _make_quoted_original(email_info.get("body_text","")) + "</body>")
|
|
|
|
|
if "</body>" in response_html.lower()
|
|
|
|
|
else response_html + _make_quoted_original(email_info.get("body_text","")))
|
|
|
|
|
reply_body = _inject_before_body_end(
|
|
|
|
|
response_html,
|
|
|
|
|
_make_quoted_original(email_info.get("body_text",""))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
msg = MIMEText(reply_body, "html", "utf-8")
|
|
|
|
|
subj = email_info.get("subject") or "[Risposta automatica] - YogaSoul"
|
|
|
|
@@ -388,9 +446,23 @@ def append_draft(mail, folder, msg):
|
|
|
|
|
# MAIN
|
|
|
|
|
# ==========================
|
|
|
|
|
def main():
|
|
|
|
|
if not assert_config():
|
|
|
|
|
# Controllo config di base
|
|
|
|
|
problems = []
|
|
|
|
|
if not isinstance(IMAP_SERVER, str) or not IMAP_SERVER.strip():
|
|
|
|
|
problems.append("imap_server mancante in config.json")
|
|
|
|
|
if not isinstance(EMAIL_ADDRESS, str) or not EMAIL_ADDRESS.strip():
|
|
|
|
|
problems.append("email_address mancante in config.json")
|
|
|
|
|
if not isinstance(EMAIL_PASSWORD, str) or not EMAIL_PASSWORD.strip():
|
|
|
|
|
problems.append("email_password mancante in config.json")
|
|
|
|
|
if not isinstance(OPENAI_API_KEY, str) or not OPENAI_API_KEY.strip():
|
|
|
|
|
problems.append("openai_api_key mancante in config.json")
|
|
|
|
|
if problems:
|
|
|
|
|
print("[CONFIG] Correggi config.json:")
|
|
|
|
|
for p in problems:
|
|
|
|
|
print(" -", p)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Login IMAP
|
|
|
|
|
try:
|
|
|
|
|
mail = imaplib.IMAP4_SSL(IMAP_SERVER)
|
|
|
|
|
mail.login(EMAIL_ADDRESS, EMAIL_PASSWORD)
|
|
|
|
@@ -403,7 +475,7 @@ def main():
|
|
|
|
|
drafts_folder, delim = ensure_drafts_folder(mail)
|
|
|
|
|
print(f"[DEBUG] drafts_folder='{drafts_folder}' delim='{delim}'")
|
|
|
|
|
|
|
|
|
|
emails = fetch_all_unseen(mail, limit=MAX_TO_PROCESS) # ⬅️ usa il limite
|
|
|
|
|
emails = fetch_all_unseen(mail, limit=MAX_TO_PROCESS)
|
|
|
|
|
if not emails:
|
|
|
|
|
print("Nessuna nuova email da processare.")
|
|
|
|
|
mail.logout()
|
|
|
|
|