ia_dev/gitea-issues/mail_common.py
Nicolas Cantu 907807f4d6 Generic project config, deploy scripts, gitea-issues, no reverse dependency
**Motivations:**
- Single config file per project (projects/<id>/conf.json)
- Project must not depend on ia_dev; only ia_dev solicits the project

**Root causes:**
- N/A (evolution)

**Correctifs:**
- N/A

**Evolutions:**
- lib/project_config.sh: resolve PROJECT_SLUG from IA_PROJECT, .ia_project, ai_project_id; PROJECT_CONFIG_PATH = projects/<id>/conf.json
- projects/lecoffreio.json moved to projects/lecoffreio/conf.json; projects/ia_dev/conf.json added
- deploy: branch-align, bump-version, change-to-all-branches, pousse, deploy-by-script-to use PROJECT_ROOT/IA_DEV_ROOT and project_config.sh; SCRIPT_REAL for symlink-safe paths
- deploy/_lib: shared colors, env-map, ssh, git-flow
- gitea-issues: mail list/mark-read/get-thread/send-reply, thread log, agent-loop, wiki scripts; lib.sh loads project config
- README: principle no dependency from host project; invoke ./ia_dev/deploy/bump-version.sh etc. from repo root

**Pages affectées:**
- README.md, projects/README.md, lib/, deploy/, gitea-issues/, projects/lecoffreio/, projects/ia_dev/
2026-03-12 22:35:15 +01:00

126 lines
4.6 KiB
Python

# Shared config and helpers for gitea-issues mail scripts (IMAP/SMTP, Gitea).
# Used by mail-list-unread, mail-send-reply, mail-create-issue-from-email, mail-mark-read.
from __future__ import annotations
import json
import os
import re
import ssl
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def repo_root() -> Path:
# When set by shell (e.g. when gitea-issues is inside ia_dev), use host repo root for .secrets, logs
env_root = os.environ.get("REPO_ROOT")
if env_root:
return Path(env_root).resolve()
issues_dir = os.environ.get("GITEA_ISSUES_DIR")
if issues_dir:
return Path(issues_dir).resolve().parent
return Path(__file__).resolve().parent.parent
def load_env_file(path: Path) -> None:
if not path.is_file():
return
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("'\"")
if key and key not in os.environ:
os.environ[key] = value
def load_imap_config() -> dict[str, str]:
root = repo_root()
env_path = root / ".secrets" / "gitea-issues" / "imap-bridge.env"
load_env_file(env_path)
ssl_verify_raw = os.environ.get("IMAP_SSL_VERIFY", "true").lower()
ssl_verify = ssl_verify_raw not in ("0", "false", "no")
return {
"host": os.environ.get("IMAP_HOST", "127.0.0.1"),
"port": os.environ.get("IMAP_PORT", "1143"),
"user": os.environ.get("IMAP_USER", ""),
"password": os.environ.get("IMAP_PASSWORD", ""),
"use_starttls": os.environ.get("IMAP_USE_STARTTLS", "true").lower() in ("1", "true", "yes"),
"ssl_verify": ssl_verify,
"filter_to": os.environ.get("MAIL_FILTER_TO", "ai.support.lecoffreio@4nkweb.com").strip().lower(),
}
def imap_ssl_context(ssl_verify: bool = True) -> ssl.SSLContext:
"""Return SSL context for IMAP STARTTLS. Use ssl_verify=False only for local Bridge with self-signed cert."""
if ssl_verify:
return ssl.create_default_context()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def load_smtp_config() -> dict[str, str]:
root = repo_root()
env_path = root / ".secrets" / "gitea-issues" / "imap-bridge.env"
load_env_file(env_path)
ssl_verify_raw = os.environ.get("IMAP_SSL_VERIFY", os.environ.get("SMTP_SSL_VERIFY", "true")).lower()
ssl_verify = ssl_verify_raw not in ("0", "false", "no")
return {
"host": os.environ.get("SMTP_HOST", os.environ.get("IMAP_HOST", "127.0.0.1")),
"port": os.environ.get("SMTP_PORT", "1025"),
"user": os.environ.get("SMTP_USER", os.environ.get("IMAP_USER", "")),
"password": os.environ.get("SMTP_PASSWORD", os.environ.get("IMAP_PASSWORD", "")),
"use_starttls": os.environ.get("SMTP_USE_STARTTLS", "true").lower() in ("1", "true", "yes"),
"ssl_verify": ssl_verify,
}
def load_gitea_config() -> dict[str, str]:
root = repo_root()
token = os.environ.get("GITEA_TOKEN")
if not token:
token_path = root / ".secrets" / "gitea-issues" / "token"
if token_path.is_file():
token = token_path.read_text(encoding="utf-8").strip()
return {
"api_url": os.environ.get("GITEA_API_URL", "https://git.4nkweb.com/api/v1").rstrip("/"),
"owner": os.environ.get("GITEA_REPO_OWNER", "4nk"),
"repo": os.environ.get("GITEA_REPO_NAME", "lecoffre_ng"),
"token": token or "",
}
def sanitize_title(raw: str, max_len: int = 200) -> str:
one_line = re.sub(r"\s+", " ", raw).strip()
return one_line[:max_len] if one_line else "(no subject)"
def create_gitea_issue(title: str, body: str) -> dict | None:
gitea = load_gitea_config()
if not gitea["token"]:
return None
url = f"{gitea['api_url']}/repos/{gitea['owner']}/{gitea['repo']}/issues"
payload = json.dumps({"title": title, "body": body}).encode("utf-8")
req = Request(
url,
data=payload,
method="POST",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"token {gitea['token']}",
},
)
try:
with urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except (HTTPError, URLError):
return None