feat: Implement AI classification and Web UI, refactor to IMAP
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
# Ignore Python virtual environment
|
||||
venv/
|
||||
|
||||
# Ignore Python cache files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Ignore IDE/editor-specific files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Ignore local development files
|
||||
.DS_Store
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Ignore configuration files containing sensitive information
|
||||
config.json
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
# 1. Base Image
|
||||
# Use a lightweight Python base image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 2. Set Working Directory
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# 3. Install Dependencies
|
||||
# Copy the requirements file first to leverage Docker's layer caching.
|
||||
# This way, dependencies are only re-installed if requirements.txt changes.
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 4. Copy Application Files
|
||||
# Copy the rest of the application source code, including the 'web' directory
|
||||
COPY . .
|
||||
|
||||
# 5. Expose Port
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 8000
|
||||
|
||||
# 6. Add a healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/ || exit 1
|
||||
|
||||
# 7. Set Command
|
||||
# Define the command to run the application.
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# AI-Powered Mail Automation Engine
|
||||
|
||||
This project provides a framework for automatically processing emails from an IMAP server based on a user-defined set of rules. It can categorize, archive, or delete emails, serving as a powerful personal email assistant.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
The engine works by connecting to an IMAP mail server, fetching unread emails, and processing them according to rules you define in a JSON file. This allows for a highly customizable workflow to manage your inbox.
|
||||
|
||||
### AI-Powered Classification
|
||||
The standout feature is the ability to use Google's Gemini large language model to classify emails based on their content. You define a list of categories, and the AI will determine the most appropriate one for each email. This allows for much more intelligent and nuanced routing than simple keyword matching.
|
||||
|
||||
### Key Components
|
||||
- `main.py`: The main entry point that runs the FastAPI web server and provides the API.
|
||||
- `processing_logic.py`: The core module that handles the email processing workflow, including the rule engine.
|
||||
- `ai_classifier.py`: The module that communicates with the Gemini API to classify emails.
|
||||
- `imap_client.py`: A class that handles all communication with the IMAP server.
|
||||
- `config.json`: Stores your sensitive connection details and API keys.
|
||||
- `rules.json`: **This is where you define your workflow.** You create rules that match email properties (like sender or subject) or AI classification results, and link them to specific actions.
|
||||
- `web/`: Contains the HTML, CSS, and JavaScript files for the web UI.
|
||||
|
||||
## The 4-Step Workflow
|
||||
|
||||
Based on the rules you set, the script can perform one of the following actions for each email:
|
||||
1. **DEVONthink (Archive):** Downloads the email as a `.eml` file and saves it to a specified folder. This folder can be indexed by DEVONthink for automatic import.
|
||||
2. **CATEGORIZE:** Moves the email to a specific mailbox on the server.
|
||||
3. **REVIEW:** Moves the email to a "Review" mailbox if it doesn't match any other rule, allowing you to handle it manually.
|
||||
4. **TRASH:** Moves the email to the Trash.
|
||||
|
||||
## Setup and Usage (Local Development)
|
||||
|
||||
### 1. Configure Credentials (`config.json`)
|
||||
|
||||
Update `config.json` with your mail server details and your Gemini API key.
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "your_email_username",
|
||||
"password": "your_email_password",
|
||||
"imap": {
|
||||
"server": "mail.hyungi.net",
|
||||
"port": 993
|
||||
},
|
||||
"gemini_api_key": "YOUR_GEMINI_API_KEY"
|
||||
}
|
||||
```
|
||||
- You can get a Gemini API key from Google AI Studio.
|
||||
|
||||
### 2. Define Your Workflow (`rules.json`)
|
||||
|
||||
This is the most important step. Open `rules.json` and define your email processing logic.
|
||||
|
||||
To use the AI, first define your categories in the `ai_categories` list. Then, create rules that use the `ai_classification_is` condition.
|
||||
|
||||
```json
|
||||
{
|
||||
"ai_categories": ["청구서", "프로젝트 업데이트", "광고", "개인 용무", "기타"],
|
||||
"rules": [
|
||||
{
|
||||
"rule_name": "AI 분류: 청구서는 DEVONthink로",
|
||||
"conditions": {
|
||||
"ai_classification_is": "청구서"
|
||||
},
|
||||
"action": {
|
||||
"type": "DEVONTHINK",
|
||||
"parameters": {
|
||||
"devonthink_inbox_path": "/path/on/nas/for/devonthink"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"rule_name": "키워드: 중요한 프로젝트",
|
||||
"conditions": {
|
||||
"subject_contains": "[Urgent Project]"
|
||||
},
|
||||
"action": {
|
||||
"type": "CATEGORIZE",
|
||||
"parameters": {
|
||||
"move_to_mailbox": "INBOX/Urgent"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_action": {
|
||||
"type": "REVIEW",
|
||||
"parameters": {
|
||||
"move_to_mailbox": "INBOX/Review"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **`ai_categories`**: A list of categories you want the AI to use.
|
||||
- **`conditions`**: Can be `subject_contains`, `from_contains`, or `ai_classification_is`.
|
||||
- **`action`**: The action to perform (`DEVONTHINK`, `CATEGORIZE`, `TRASH`, `REVIEW`).
|
||||
|
||||
### 3. Installation
|
||||
|
||||
This project uses a virtual environment to manage dependencies.
|
||||
|
||||
**Create the environment (only once):**
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
```
|
||||
|
||||
**Activate the environment and install packages:**
|
||||
(Activate the environment every time you open a new terminal)
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Running the Web Server
|
||||
|
||||
With the virtual environment activated, run the FastAPI server:
|
||||
|
||||
```bash
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
- The `--reload` flag automatically restarts the server when you make code changes.
|
||||
- Access the web UI by opening `http://localhost:8000` in your browser.
|
||||
|
||||
## Docker
|
||||
|
||||
You can also build and run this application as a Docker container.
|
||||
|
||||
### 1. Build the Image
|
||||
```bash
|
||||
docker build -t mail-manager .
|
||||
```
|
||||
|
||||
### 2. Run the Container
|
||||
```bash
|
||||
# Make sure your config.json and rules.json are correctly filled out
|
||||
docker run --rm -p 8000:8000 -v $(pwd)/config.json:/app/config.json -v $(pwd)/rules.json:/app/rules.json mail-manager
|
||||
```
|
||||
- `-p 8000:8000`: This maps port 8000 on your local machine to port 8000 in the container, making the web UI accessible at `http://localhost:8000`.
|
||||
- `-v`: Using volumes mounts your local configuration files into the container, making it easy to manage them without rebuilding the image.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Advanced Rule Conditions:** Add more complex conditions to the rule engine (e.g., regex matching, checking for attachments).
|
||||
- **Improved Web UI:** Enhance the web interface for a more user-friendly rule editing experience (e.g., with forms instead of raw JSON).
|
||||
- **Scheduler:** Implement a scheduler within the application to run the email processing task automatically at regular intervals.
|
||||
94
ai_classifier.py
Normal file
94
ai_classifier.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import google.generativeai as genai
|
||||
import os
|
||||
|
||||
def classify_email(api_key, email_content, categories):
|
||||
"""
|
||||
Classifies the given email content into one of the provided categories using the Gemini API.
|
||||
|
||||
Args:
|
||||
api_key (str): The Gemini API key.
|
||||
email_content (str): The content of the email (subject + body).
|
||||
categories (list): A list of strings representing the possible categories.
|
||||
|
||||
Returns:
|
||||
str: The chosen category, or None if classification fails.
|
||||
"""
|
||||
if not api_key or api_key == "YOUR_GEMINI_API_KEY":
|
||||
print("! 경고: Gemini API 키가 설정되지 않았습니다. AI 분류를 건너뜁니다.")
|
||||
return None
|
||||
|
||||
try:
|
||||
genai.configure(api_key=api_key)
|
||||
except Exception as e:
|
||||
print(f"Gemini API 설정 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
# For safety, let's use a model that's less likely to refuse classification
|
||||
model = genai.GenerativeModel('gemini-1.5-flash')
|
||||
|
||||
# Construct the prompt
|
||||
prompt = f"""
|
||||
Analyze the following email content and classify it into one of the following categories.
|
||||
Your response must be ONLY ONE of the category names provided. Do not add any extra text, explanation, or punctuation.
|
||||
|
||||
Categories: {', '.join(categories)}
|
||||
|
||||
Email Content:
|
||||
---
|
||||
{email_content}
|
||||
---
|
||||
|
||||
Category:
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
# The response text should be one of the categories
|
||||
result_category = response.text.strip()
|
||||
|
||||
# Validate that the model returned a valid category
|
||||
if result_category in categories:
|
||||
print(f"-> AI 분류 결과: '{result_category}'")
|
||||
return result_category
|
||||
else:
|
||||
print(f"! 경고: AI가 유효하지 않은 카테고리 '{result_category}'를 반환했습니다.")
|
||||
# Fallback or simply return None
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Gemini API 호출 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
if __name__ == '__main__':
|
||||
# --- 사용 예시 ---
|
||||
# 1. 아래 YOUR_API_KEY를 실제 키로 변경하세요.
|
||||
# 2. 터미널에서 `pip install google-generativeai` 를 실행하세요.
|
||||
# 3. `python ai_classifier.py` 명령으로 테스트하세요.
|
||||
|
||||
test_api_key = os.environ.get("GEMINI_API_KEY", "YOUR_GEMINI_API_KEY") # 실제 키로 교체 필요
|
||||
|
||||
test_email = """
|
||||
Subject: Invoice INV-2024-001 from Office Supplies Co.
|
||||
|
||||
Dear Customer,
|
||||
|
||||
Please find attached your invoice for the recent purchase of office supplies.
|
||||
The total amount due is $125.50.
|
||||
|
||||
Thank you for your business.
|
||||
|
||||
Sincerely,
|
||||
Office Supplies Co.
|
||||
"""
|
||||
|
||||
test_categories = ["청구서", "프로젝트 업데이트", "광고", "개인 용무"]
|
||||
|
||||
if test_api_key != "YOUR_GEMINI_API_KEY":
|
||||
classified_category = classify_email(test_api_key, test_email, test_categories)
|
||||
|
||||
if classified_category:
|
||||
print(f"\n최종 분류: {classified_category}")
|
||||
else:
|
||||
print("\n분류에 실패했습니다.")
|
||||
else:
|
||||
print("\n테스트를 위해 'YOUR_GEMINI_API_KEY'를 실제 API 키로 바꿔주세요.")
|
||||
175
imap_client.py
Normal file
175
imap_client.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import imaplib
|
||||
import email
|
||||
from email.header import decode_header
|
||||
import os
|
||||
|
||||
class ImapMailClient:
|
||||
def __init__(self, server, port, username, password):
|
||||
self.server = server
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.mail = None
|
||||
|
||||
def connect(self):
|
||||
"""IMAP 서버에 연결하고 로그인합니다."""
|
||||
try:
|
||||
self.mail = imaplib.IMAP4_SSL(self.server, self.port)
|
||||
self.mail.login(self.username, self.password)
|
||||
print("IMAP 서버에 성공적으로 연결했습니다.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"IMAP 연결 오류: {e}")
|
||||
return False
|
||||
|
||||
def fetch_unread_emails(self, mailbox='inbox'):
|
||||
"""지정된 메일함에서 읽지 않은 모든 이메일의 UID를 가져옵니다."""
|
||||
if not self.mail:
|
||||
print("오류: 먼저 connect()를 호출해야 합니다.")
|
||||
return []
|
||||
|
||||
try:
|
||||
self.mail.select(mailbox)
|
||||
status, messages = self.mail.search(None, 'UNSEEN')
|
||||
if status != 'OK':
|
||||
print(f"'{mailbox}' 메일함에서 UNSEEN 이메일을 검색하지 못했습니다.")
|
||||
return []
|
||||
|
||||
email_uids = messages[0].split()
|
||||
print(f"'{mailbox}'에서 {len(email_uids)}개의 새로운 메일을 발견했습니다.")
|
||||
return email_uids
|
||||
except Exception as e:
|
||||
print(f"읽지 않은 이메일을 가져오는 중 오류 발생: {e}")
|
||||
return []
|
||||
|
||||
def fetch_email(self, uid):
|
||||
"""주어진 UID를 가진 이메일의 상세 정보를 가져옵니다 (보낸사람, 제목, 본문)."""
|
||||
if not self.mail:
|
||||
return None
|
||||
|
||||
try:
|
||||
# UID로 메일 가져오기
|
||||
status, msg_data = self.mail.fetch(uid, '(RFC822)')
|
||||
if status != 'OK':
|
||||
return None
|
||||
|
||||
for response_part in msg_data:
|
||||
if isinstance(response_part, tuple):
|
||||
msg = email.message_from_bytes(response_part[1])
|
||||
|
||||
# 헤더 디코딩
|
||||
subject, encoding = decode_header(msg["subject"])[0]
|
||||
if isinstance(subject, bytes):
|
||||
subject = subject.decode(encoding if encoding else "utf-8")
|
||||
|
||||
from_ = msg.get("From")
|
||||
|
||||
# 본문 내용 추출
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
content_disposition = str(part.get("Content-Disposition"))
|
||||
if content_type == "text/plain" and "attachment" not in content_disposition:
|
||||
payload = part.get_payload(decode=True)
|
||||
charset = part.get_content_charset()
|
||||
body = payload.decode(charset if charset else "utf-8")
|
||||
break
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
charset = msg.get_content_charset()
|
||||
body = payload.decode(charset if charset else "utf-8")
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"from": from_,
|
||||
"subject": subject,
|
||||
"body": body.strip(),
|
||||
"raw": response_part[1] # .eml 파일 저장을 위한 원본 데이터
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"UID {uid} 이메일을 가져오는 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def move_email(self, uid, target_mailbox):
|
||||
"""이메일을 다른 메일함으로 이동합니다."""
|
||||
if not self.mail:
|
||||
return False
|
||||
try:
|
||||
# 메일을 대상 메일함으로 복사
|
||||
self.mail.copy(uid, target_mailbox)
|
||||
# 원본 메일을 삭제 플래그 처리
|
||||
self.mail.store(uid, '+FLAGS', '\Deleted')
|
||||
# 메일함에서 영구 삭제
|
||||
self.mail.expunge()
|
||||
print(f"메일(UID: {uid})을 '{target_mailbox}'(으)로 이동했습니다.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"메일 이동 중 오류 발생: {e}")
|
||||
return False
|
||||
|
||||
def delete_email(self, uid):
|
||||
"""이메일을 삭제합니다 (휴지통으로 이동)."""
|
||||
# Synology MailPlus에서는 'Deleted' 플래그가 휴지통으로 이동시킵니다.
|
||||
return self.move_email(uid, 'Trash')
|
||||
|
||||
def download_email(self, email_data, directory):
|
||||
"""이메일 원본 데이터를 .eml 파일로 저장합니다."""
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
# 파일명으로 사용할 수 없는 문자 제거
|
||||
safe_subject = "".join([c for c in email_data['subject'] if c.isalpha() or c.isdigit() or c==' ']).rstrip()
|
||||
filename = f"{email_data['uid'].decode()}_{safe_subject}.eml"
|
||||
filepath = os.path.join(directory, filename)
|
||||
|
||||
try:
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(email_data['raw'])
|
||||
print(f"메일을 '{filepath}'에 다운로드했습니다.")
|
||||
return filepath
|
||||
except Exception as e:
|
||||
print(f"메일 다운로드 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""서버 연결을 종료합니다."""
|
||||
if self.mail:
|
||||
self.mail.close()
|
||||
self.mail.logout()
|
||||
print("IMAP 서버 연결을 종료했습니다.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# --- 사용 예시 ---
|
||||
# 1. config.json 파일에 imap 정보를 정확히 입력했는지 확인하세요.
|
||||
import json
|
||||
with open('config.json') as f:
|
||||
config = json.load(f)
|
||||
|
||||
imap_client = ImapMailClient(
|
||||
server=config['imap']['server'],
|
||||
port=config['imap']['port'],
|
||||
username=config['username'],
|
||||
password=config['password']
|
||||
)
|
||||
|
||||
if imap_client.connect():
|
||||
unread_uids = imap_client.fetch_unread_emails()
|
||||
if unread_uids:
|
||||
# 첫 번째 안 읽은 메일 테스트
|
||||
first_uid = unread_uids[0]
|
||||
email_details = imap_client.fetch_email(first_uid)
|
||||
if email_details:
|
||||
print("\n--- 첫 번째 메일 정보 ---")
|
||||
print(f" 보낸사람: {email_details['from']}")
|
||||
print(f" 제목: {email_details['subject']}")
|
||||
print("------------------------\n")
|
||||
|
||||
# 테스트: 'Test' 메일함으로 이동
|
||||
# imap_client.move_email(first_uid, 'Test')
|
||||
|
||||
# 테스트: .eml 파일로 다운로드
|
||||
# imap_client.download_email(email_details, 'downloaded_mails')
|
||||
|
||||
imap_client.close()
|
||||
52
main.py
Normal file
52
main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import FastAPI, Request, BackgroundTasks
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import json
|
||||
from processing_logic import run_email_processing
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Mount the 'web' directory to serve static files (CSS, JS)
|
||||
app.mount("/static", StaticFiles(directory="web"), name="static")
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
"""Serves the main index.html file."""
|
||||
return FileResponse('web/index.html')
|
||||
|
||||
@app.get("/api/rules")
|
||||
def get_rules():
|
||||
"""Reads and returns the current rules from rules.json."""
|
||||
try:
|
||||
with open('rules.json', 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {"error": "rules.json not found"}
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Could not parse rules.json"}
|
||||
|
||||
@app.post("/api/rules")
|
||||
async def save_rules(request: Request):
|
||||
"""Receives new rules as JSON and overwrites rules.json."""
|
||||
try:
|
||||
new_rules = await request.json()
|
||||
with open('rules.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(new_rules, f, indent=2, ensure_ascii=False)
|
||||
return {"message": "Rules saved successfully."}
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Invalid JSON format received."}
|
||||
except Exception as e:
|
||||
return {"error": f"An error occurred: {e}"}
|
||||
|
||||
@app.post("/api/run-processing")
|
||||
async def trigger_processing(background_tasks: BackgroundTasks):
|
||||
"""Triggers the email processing task in the background."""
|
||||
background_tasks.add_task(run_email_processing)
|
||||
return {"message": "Email processing task has been started in the background."}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
# This is for local development. For production, you'd run uvicorn directly.
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
134
processing_logic.py
Normal file
134
processing_logic.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
from imap_client import ImapMailClient
|
||||
from ai_classifier import classify_email
|
||||
|
||||
def load_config(path='config.json'):
|
||||
"""설정 파일을 로드합니다."""
|
||||
print(f"'{path}' 파일에서 설정 로드 중...")
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_rules(path='rules.json'):
|
||||
"""규칙 파일을 로드합니다."""
|
||||
print(f"'{path}' 파일에서 규칙 로드 중...")
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def check_conditions(email_details, conditions):
|
||||
"""이메일이 주어진 조건을 만족하는지 확인합니다."""
|
||||
for key, value in conditions.items():
|
||||
if key == 'from_contains':
|
||||
if value.lower() not in email_details.get('from', '').lower():
|
||||
return False
|
||||
elif key == 'subject_contains':
|
||||
if value.lower() not in email_details.get('subject', '').lower():
|
||||
return False
|
||||
elif key == 'ai_classification_is':
|
||||
if value != email_details.get('ai_category'):
|
||||
return False
|
||||
# 여기에 'to_contains', 'body_contains' 등 다른 조건을 추가할 수 있습니다.
|
||||
return True
|
||||
|
||||
def process_emails(imap_client, rules_config, gemini_api_key):
|
||||
"""
|
||||
읽지 않은 이메일을 가져와서 규칙에 따라 처리합니다.
|
||||
"""
|
||||
unread_uids = imap_client.fetch_unread_emails()
|
||||
ai_categories = rules_config.get('ai_categories', [])
|
||||
|
||||
for uid in unread_uids:
|
||||
email_details = imap_client.fetch_email(uid)
|
||||
if not email_details:
|
||||
continue
|
||||
|
||||
print(f"\n[처리 시작] 메일 UID: {uid.decode()}, 제목: {email_details['subject']}")
|
||||
|
||||
# AI 분류 실행 (필요한 경우)
|
||||
# AI 규칙이 하나라도 있을 때만 API를 호출하도록 최적화할 수 있음
|
||||
email_details['ai_category'] = None
|
||||
if ai_categories:
|
||||
email_content_for_ai = f"Subject: {email_details['subject']}\n\nBody:\n{email_details['body']}"
|
||||
email_details['ai_category'] = classify_email(gemini_api_key, email_content_for_ai, ai_categories)
|
||||
|
||||
# 규칙 검사
|
||||
matched_rule = None
|
||||
for rule in rules_config.get('rules', []):
|
||||
if check_conditions(email_details, rule.get('conditions', {})):
|
||||
matched_rule = rule
|
||||
break
|
||||
|
||||
action_info = None
|
||||
if matched_rule:
|
||||
print(f"-> 규칙 '{matched_rule.get('rule_name', '이름 없음')}'에 해당합니다.")
|
||||
action_info = matched_rule.get('action')
|
||||
else:
|
||||
print("-> 일치하는 규칙이 없어 기본 행동을 실행합니다.")
|
||||
action_info = rules_config.get('default_action')
|
||||
|
||||
if action_info:
|
||||
execute_action(imap_client, email_details, action_info)
|
||||
else:
|
||||
print("-> 실행할 행동이 정의되지 않았습니다.")
|
||||
|
||||
|
||||
def execute_action(client, email, action):
|
||||
"""규칙에 따른 행동을 실행합니다."""
|
||||
action_type = action.get('type')
|
||||
params = action.get('parameters', {})
|
||||
uid = email['uid']
|
||||
|
||||
if action_type == 'DEVONTHINK':
|
||||
print("-> 행동: DEVONthink로 저장")
|
||||
inbox_path = params.get('devonthink_inbox_path')
|
||||
if inbox_path:
|
||||
client.download_email(email, inbox_path)
|
||||
# 다운로드 후 메일 삭제 또는 다른 곳으로 이동 등 추가 행동을 원하면 여기에 구현
|
||||
client.delete_email(uid) # 예: 다운로드 후 휴지통으로 이동
|
||||
else:
|
||||
print("! 경고: 'devonthink_inbox_path'가 rules.json에 지정되지 않았습니다.")
|
||||
|
||||
elif action_type == 'CATEGORIZE':
|
||||
target_mailbox = params.get('move_to_mailbox')
|
||||
# TODO: IMAP으로는 Synology의 '레이블'을 직접 제어하기 어려움.
|
||||
# 대신 메일함으로 이동하는 것으로 대체.
|
||||
print(f"-> 행동: '{target_mailbox}' 메일함으로 이동")
|
||||
if target_mailbox:
|
||||
client.move_email(uid, target_mailbox)
|
||||
else:
|
||||
print("! 경고: 'move_to_mailbox'가 rules.json에 지정되지 않았습니다.")
|
||||
|
||||
elif action_type == 'REVIEW':
|
||||
target_mailbox = params.get('move_to_mailbox')
|
||||
print(f"-> 행동: '{target_mailbox}' 메일함으로 이동 (검토 필요)")
|
||||
if target_mailbox:
|
||||
client.move_email(uid, target_mailbox)
|
||||
else:
|
||||
print("! 경고: 'move_to_mailbox'가 rules.json에 지정되지 않았습니다.")
|
||||
|
||||
elif action_type == 'TRASH':
|
||||
print("-> 행동: 휴지통으로 이동")
|
||||
client.delete_email(uid)
|
||||
|
||||
else:
|
||||
print(f"! 경고: 알 수 없는 행동 타입 '{action_type}' 입니다.")
|
||||
|
||||
|
||||
def run_email_processing():
|
||||
"""메인 실행 함수"""
|
||||
config = load_config()
|
||||
rules_config = load_rules()
|
||||
|
||||
imap_client = ImapMailClient(
|
||||
server=config['imap']['server'],
|
||||
port=config['imap']['port'],
|
||||
username=config['username'],
|
||||
password=config['password']
|
||||
)
|
||||
|
||||
gemini_api_key = config.get('gemini_api_key')
|
||||
|
||||
if imap_client.connect():
|
||||
process_emails(imap_client, rules_config, gemini_api_key)
|
||||
imap_client.close()
|
||||
else:
|
||||
print("프로세스를 시작할 수 없습니다. IMAP 연결 정보를 확인하세요.")
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
google-generativeai
|
||||
45
rules.json
Normal file
45
rules.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"ai_categories": ["청구서", "프로젝트 업데이트", "광고", "개인 용무", "기타"],
|
||||
"rules": [
|
||||
{
|
||||
"rule_name": "AI 분류: 청구서 보관",
|
||||
"conditions": {
|
||||
"ai_classification_is": "청구서"
|
||||
},
|
||||
"action": {
|
||||
"type": "DEVONTHINK",
|
||||
"parameters": {
|
||||
"devonthink_inbox_path": "/path/to/your/devonthink_inbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"rule_name": "키워드: Project-A 관련 메일",
|
||||
"conditions": {
|
||||
"from_contains": "manager@example.com",
|
||||
"subject_contains": "[Project-A]"
|
||||
},
|
||||
"action": {
|
||||
"type": "CATEGORIZE",
|
||||
"parameters": {
|
||||
"move_to_mailbox": "프로젝트/Project-A"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"rule_name": "키워드: 광고 메일 삭제",
|
||||
"conditions": {
|
||||
"from_contains": "ad@spam.com"
|
||||
},
|
||||
"action": {
|
||||
"type": "TRASH"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_action": {
|
||||
"type": "REVIEW",
|
||||
"parameters": {
|
||||
"move_to_mailbox": "검토할 메일"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
web/index.html
Normal file
35
web/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Mail Manager - Rule Editor</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Mail Processing Rule Editor</h1>
|
||||
<p>Edit the rules in JSON format below. Click 'Save Rules' to apply them.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="editor-container">
|
||||
<textarea id="rules-editor" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="save-button">Save Rules</button>
|
||||
<button id="run-button">Run Processing Now</button>
|
||||
</div>
|
||||
<div id="status-message"></div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>AI Mail Server</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/static/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
85
web/script.js
Normal file
85
web/script.js
Normal file
@@ -0,0 +1,85 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const editor = document.getElementById('rules-editor');
|
||||
const saveButton = document.getElementById('save-button');
|
||||
const runButton = document.getElementById('run-button');
|
||||
const statusMessage = document.getElementById('status-message');
|
||||
|
||||
// Function to display status messages
|
||||
const showStatus = (message, isError = false) => {
|
||||
statusMessage.textContent = message;
|
||||
statusMessage.className = isError ? 'error' : 'success';
|
||||
};
|
||||
|
||||
// 1. Fetch initial rules on page load
|
||||
fetch('/api/rules')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showStatus(`Error loading rules: ${data.error}`, true);
|
||||
} else {
|
||||
// Pretty-print the JSON with 2 spaces
|
||||
editor.value = JSON.stringify(data, null, 2);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showStatus('Failed to fetch rules. Is the server running?', true);
|
||||
console.error('Fetch error:', error);
|
||||
});
|
||||
|
||||
// 2. Add event listener for the Save button
|
||||
saveButton.addEventListener('click', () => {
|
||||
let rulesContent;
|
||||
try {
|
||||
// We parse it first to ensure it's valid JSON before sending
|
||||
rulesContent = JSON.parse(editor.value);
|
||||
} catch (error) {
|
||||
showStatus('Invalid JSON format. Please correct it before saving.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/rules', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(rulesContent),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showStatus(`Error saving rules: ${data.error}`, true);
|
||||
} else {
|
||||
showStatus(data.message || 'Rules saved successfully!', false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showStatus('Failed to save rules. An unknown error occurred.', true);
|
||||
console.error('Save error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Add event listener for the Run button
|
||||
runButton.addEventListener('click', () => {
|
||||
showStatus('Requesting to start email processing...', false);
|
||||
fetch('/api/run-processing', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showStatus(`Error starting process: ${data.error}`, true);
|
||||
} else {
|
||||
showStatus(data.message || 'Processing started in the background.', false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showStatus('Failed to start processing. An unknown error occurred.', true);
|
||||
console.error('Run error:', error);
|
||||
});
|
||||
});
|
||||
});
|
||||
116
web/style.css
Normal file
116
web/style.css
Normal file
@@ -0,0 +1,116 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f4f7f9;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 80%;
|
||||
max-width: 900px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#rules-editor {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
font-family: "SF Mono", "Fira Code", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#save-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#save-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
#run-button {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#run-button:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
#status-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
|
||||
#status-message.success {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#status-message.error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
display: block;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #bdc3c7;
|
||||
}
|
||||
Reference in New Issue
Block a user