initial setup
This commit is contained in:
commit
15aa874800
89
pythonechoserver.py
Normal file
89
pythonechoserver.py
Normal file
@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
# Override to add timestamp
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] {format % args}")
|
||||
|
||||
def do_POST(self):
|
||||
self.handle_request()
|
||||
|
||||
def do_GET(self):
|
||||
self.handle_request()
|
||||
|
||||
def do_PUT(self):
|
||||
self.handle_request()
|
||||
|
||||
def do_DELETE(self):
|
||||
self.handle_request()
|
||||
|
||||
def handle_request(self):
|
||||
print("=" * 80)
|
||||
print(f"📧 WEBHOOK RECEIVED - {self.command} {self.path}")
|
||||
print("=" * 80)
|
||||
|
||||
# Log basic info
|
||||
print(f"Method: {self.command}")
|
||||
print(f"Path: {self.path}")
|
||||
print(f"Remote IP: {self.client_address[0]}")
|
||||
|
||||
# Log headers
|
||||
print("\n📋 HEADERS:")
|
||||
for header, value in self.headers.items():
|
||||
print(f" {header}: {value}")
|
||||
|
||||
# Read and log body
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
if content_length > 0:
|
||||
body = self.rfile.read(content_length)
|
||||
print(f"\n📄 RAW BODY:")
|
||||
print(f" {body.decode('utf-8', errors='replace')}")
|
||||
|
||||
# Try to parse JSON
|
||||
try:
|
||||
if self.headers.get('Content-Type', '').startswith('application/json'):
|
||||
json_data = json.loads(body)
|
||||
print(f"\n🎯 JSON PAYLOAD:")
|
||||
print(json.dumps(json_data, indent=2))
|
||||
|
||||
# Highlight specific fields
|
||||
if isinstance(json_data, dict):
|
||||
for key in ['email', 'event', 'subject', 'sender', 'timestamp']:
|
||||
if key in json_data:
|
||||
print(f" {key.upper()}: {json_data[key]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Send response
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
|
||||
response = {
|
||||
"status": "received",
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
self.wfile.write(json.dumps(response).encode())
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 Starting Simple Webhook Echo Server...")
|
||||
print("📡 Listening on http://localhost:8080")
|
||||
print("⚡ Press Ctrl+C to stop")
|
||||
print("=" * 80)
|
||||
|
||||
server = HTTPServer(('0.0.0.0', 8080), WebhookHandler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Shutting down server...")
|
||||
server.server_close()
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
Django==6.0a1
|
||||
djangorestframework==3.16.1
|
||||
django-cors-headers==4.9.0
|
||||
psycopg2-binary==2.9.10
|
||||
cryptography==46.0.1
|
||||
requests==2.32.5
|
||||
imapclient==3.0.1
|
||||
celery==5.5.3
|
||||
redis==6.4.0
|
||||
python-dotenv==1.1.1
|
||||
gunicorn==23.0.0
|
||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
BIN
src/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
src/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/settings.cpython-311.pyc
Normal file
BIN
src/core/__pycache__/settings.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/urls.cpython-311.pyc
Normal file
BIN
src/core/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/urls.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/wsgi.cpython-311.pyc
Normal file
BIN
src/core/__pycache__/wsgi.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/wsgi.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/wsgi.cpython-313.pyc
Normal file
Binary file not shown.
16
src/core/asgi.py
Normal file
16
src/core/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for imap_relay project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
BIN
src/core/db.sqlite3
Normal file
BIN
src/core/db.sqlite3
Normal file
Binary file not shown.
175
src/core/settings/__init__.py
Normal file
175
src/core/settings/__init__.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""
|
||||
Django settings for imap_relay project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.1.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'd$kxa%nt#t1td&$$2%vg+ec&%!6fn*(ii)@kx)wb7sm183^bim'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'relay',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'core.wsgi.application'
|
||||
ASGI_APPLICATION = 'core.asgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'relay.utils.authentication.APITokenAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.AllowAny', # Allow any by default
|
||||
],
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# Logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'loggers': {
|
||||
'relay.utils.imap_manager': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'MST'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
API_TOKEN = os.getenv('API_TOKEN', 'v3rys3cr37k3y')
|
||||
ENCRYPTION_KEY = os.getenv('ENCRYPTION_KEY', 'cKJj0iMVeg9gwGjN_D6aCYayg-gCBe-uO8mPp6rwz-8=')
|
||||
BIN
src/core/settings/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
src/core/settings/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/core/settings/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/core/settings/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
22
src/core/urls.py
Normal file
22
src/core/urls.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""imap_relay URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/3.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('relay.urls')),
|
||||
]
|
||||
16
src/core/wsgi.py
Normal file
16
src/core/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for imap_relay project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
22
src/manage.py
Executable file
22
src/manage.py
Executable file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
BIN
src/relay/__pycache__/admin.cpython-313.pyc
Normal file
BIN
src/relay/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/__pycache__/apps.cpython-313.pyc
Normal file
BIN
src/relay/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/__pycache__/models.cpython-313.pyc
Normal file
BIN
src/relay/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/__pycache__/serializers.cpython-313.pyc
Normal file
BIN
src/relay/__pycache__/serializers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/__pycache__/urls.cpython-313.pyc
Normal file
BIN
src/relay/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/__pycache__/views.cpython-313.pyc
Normal file
BIN
src/relay/__pycache__/views.cpython-313.pyc
Normal file
Binary file not shown.
25
src/relay/admin.py
Normal file
25
src/relay/admin.py
Normal file
@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
from relay.models import IMAPAccount
|
||||
|
||||
@admin.register(IMAPAccount)
|
||||
class IMAPAccountAdmin(admin.ModelAdmin):
|
||||
list_display = ['email', 'imap_server', 'auth_type', 'is_active', 'last_activity', 'created_at']
|
||||
list_filter = ['auth_type', 'is_active', 'imap_server']
|
||||
search_fields = ['email', 'username']
|
||||
readonly_fields = ['created_at', 'updated_at', 'last_activity']
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('email', 'username', 'webhook_url')
|
||||
}),
|
||||
('IMAP Settings', {
|
||||
'fields': ('imap_server', 'imap_port', 'auth_type')
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('is_active', 'last_activity')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
23
src/relay/apps.py
Normal file
23
src/relay/apps.py
Normal file
@ -0,0 +1,23 @@
|
||||
from django.apps import AppConfig
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RelayConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'relay'
|
||||
|
||||
def ready(self):
|
||||
# Start IMAP connection manager when Django starts
|
||||
from relay.utils.imap_manager import connection_manager
|
||||
import threading
|
||||
|
||||
# Start in a separate thread to avoid blocking Django startup
|
||||
def start_manager():
|
||||
try:
|
||||
connection_manager.start_manager()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start IMAP manager: {e}")
|
||||
|
||||
thread = threading.Thread(target=start_manager, daemon=True)
|
||||
thread.start()
|
||||
0
src/relay/management/__init__.py
Normal file
0
src/relay/management/__init__.py
Normal file
BIN
src/relay/management/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/relay/management/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
0
src/relay/management/commands/__init__.py
Normal file
0
src/relay/management/commands/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
43
src/relay/management/commands/list_folders.py
Normal file
43
src/relay/management/commands/list_folders.py
Normal file
@ -0,0 +1,43 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from relay.models import IMAPAccount
|
||||
import imaplib
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'List all IMAP folders for an account'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('email', help='Email address to check folders for')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
email = options['email']
|
||||
|
||||
try:
|
||||
account = IMAPAccount.objects.get(email=email)
|
||||
except IMAPAccount.DoesNotExist:
|
||||
self.stderr.write(f"Account {email} not found")
|
||||
return
|
||||
|
||||
try:
|
||||
imap = imaplib.IMAP4_SSL(account.imap_server, account.imap_port)
|
||||
imap.login(account.username, account.password)
|
||||
|
||||
result, folder_list = imap.list()
|
||||
if result == 'OK':
|
||||
self.stdout.write(f"📁 Folders for {email}:")
|
||||
for folder_line in folder_list:
|
||||
folder_str = folder_line.decode('utf-8')
|
||||
self.stdout.write(f" {folder_str}")
|
||||
|
||||
# Try to extract just the folder name
|
||||
parts = folder_str.split(' "/" ')
|
||||
if len(parts) >= 2:
|
||||
folder_name = parts[1].strip('"')
|
||||
if 'sent' in folder_name.lower():
|
||||
self.stdout.write(f" ✅ SENT FOLDER: {folder_name}")
|
||||
else:
|
||||
self.stderr.write(f"Failed to list folders: {result}")
|
||||
|
||||
imap.logout()
|
||||
|
||||
except Exception as e:
|
||||
self.stderr.write(f"Error: {e}")
|
||||
76
src/relay/management/commands/start_imap_connections.py
Normal file
76
src/relay/management/commands/start_imap_connections.py
Normal file
@ -0,0 +1,76 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from relay.utils.imap_manager import connection_manager
|
||||
from relay.models import IMAPAccount
|
||||
import time
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Start IMAP IDLE connections for all active accounts'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--restart',
|
||||
action='store_true',
|
||||
help='Stop existing connections and restart them',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options['restart']:
|
||||
self.stdout.write("🛑 Stopping existing connections...")
|
||||
connection_manager.stop_manager()
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write("🚀 Starting IMAP Connection Manager...")
|
||||
|
||||
# Always start the manager fresh
|
||||
if not connection_manager.running:
|
||||
connection_manager.start_manager()
|
||||
|
||||
# Give it a moment to start
|
||||
time.sleep(2)
|
||||
|
||||
# Force load accounts (ignore the table check)
|
||||
try:
|
||||
active_accounts = IMAPAccount.objects.filter(is_active=True)
|
||||
self.stdout.write(f"📧 Found {active_accounts.count()} active accounts")
|
||||
|
||||
if active_accounts.count() == 0:
|
||||
self.stdout.write(self.style.WARNING("⚠️ No active accounts found"))
|
||||
return
|
||||
|
||||
for account in active_accounts:
|
||||
if account.email not in connection_manager.connections:
|
||||
self.stdout.write(f"➕ Adding connections for {account.email}")
|
||||
try:
|
||||
connection_manager.add_account(account)
|
||||
self.stdout.write(f"✅ Successfully added {account.email}")
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"❌ Failed to add {account.email}: {e}")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(f"✅ {account.email} already connected")
|
||||
|
||||
# Show detailed status
|
||||
time.sleep(2) # Give connections time to establish
|
||||
total_connections = sum(len(conns) for conns in connection_manager.connections.values())
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"✅ IMAP Manager running with {total_connections} total connections "
|
||||
f"across {len(connection_manager.connections)} accounts"
|
||||
)
|
||||
)
|
||||
|
||||
# List all connections with their status
|
||||
for email, connections in connection_manager.connections.items():
|
||||
folders = []
|
||||
for conn in connections:
|
||||
status = "🟢" if conn.running else "🔴"
|
||||
folders.append(f"{status}{conn.folder_name}")
|
||||
self.stdout.write(f" 📁 {email}: {', '.join(folders)}")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"❌ Error loading accounts: {e}")
|
||||
)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
36
src/relay/migrations/0001_initial.py
Normal file
36
src/relay/migrations/0001_initial.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0a1 on 2025-09-23 23:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='IMAPAccount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||
('imap_server', models.CharField(max_length=255)),
|
||||
('imap_port', models.IntegerField(default=993)),
|
||||
('username', models.CharField(max_length=255)),
|
||||
('_password', models.BinaryField(blank=True, null=True)),
|
||||
('_oauth_token', models.BinaryField(blank=True, null=True)),
|
||||
('auth_type', models.CharField(choices=[('password', 'Password'), ('oauth', 'OAuth')], default='password', max_length=50)),
|
||||
('webhook_url', models.URLField()),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_activity', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'imap_accounts',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
src/relay/migrations/__init__.py
Normal file
0
src/relay/migrations/__init__.py
Normal file
BIN
src/relay/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
BIN
src/relay/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/relay/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
63
src/relay/models.py
Normal file
63
src/relay/models.py
Normal file
@ -0,0 +1,63 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from relay.utils.encryption import encryption
|
||||
|
||||
class IMAPAccount(models.Model):
|
||||
AUTH_TYPES = [
|
||||
('password', 'Password'),
|
||||
('oauth', 'OAuth'),
|
||||
]
|
||||
|
||||
email = models.EmailField(unique=True, db_index=True)
|
||||
imap_server = models.CharField(max_length=255)
|
||||
imap_port = models.IntegerField(default=993)
|
||||
username = models.CharField(max_length=255)
|
||||
_password = models.BinaryField(blank=True, null=True)
|
||||
_oauth_token = models.BinaryField(blank=True, null=True)
|
||||
auth_type = models.CharField(max_length=50, choices=AUTH_TYPES, default='password')
|
||||
webhook_url = models.URLField()
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_activity = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'imap_accounts'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
if self._password:
|
||||
return encryption.decrypt(self._password)
|
||||
return None
|
||||
|
||||
@password.setter
|
||||
def password(self, pwd):
|
||||
if pwd:
|
||||
self._password = encryption.encrypt(pwd)
|
||||
else:
|
||||
self._password = None
|
||||
|
||||
@property
|
||||
def oauth_token(self):
|
||||
if self._oauth_token:
|
||||
return encryption.decrypt(self._oauth_token)
|
||||
return None
|
||||
|
||||
@oauth_token.setter
|
||||
def oauth_token(self, token):
|
||||
if token:
|
||||
self._oauth_token = encryption.encrypt(token)
|
||||
else:
|
||||
self._oauth_token = None
|
||||
|
||||
def update_activity(self):
|
||||
self.last_activity = timezone.now()
|
||||
self.save(update_fields=['last_activity'])
|
||||
|
||||
def set_inactive(self):
|
||||
self.is_active = False
|
||||
self.save(update_fields=['is_active'])
|
||||
68
src/relay/serializers.py
Normal file
68
src/relay/serializers.py
Normal file
@ -0,0 +1,68 @@
|
||||
from rest_framework import serializers
|
||||
from relay.models import IMAPAccount
|
||||
|
||||
class IMAPAccountCreateSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
oauth_token = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = IMAPAccount
|
||||
fields = [
|
||||
'email', 'imap_server', 'imap_port', 'username',
|
||||
'password', 'oauth_token', 'auth_type', 'webhook_url'
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
password = validated_data.pop('password', None)
|
||||
oauth_token = validated_data.pop('oauth_token', None)
|
||||
|
||||
account = IMAPAccount.objects.create(**validated_data)
|
||||
|
||||
if password:
|
||||
account.password = password
|
||||
if oauth_token:
|
||||
account.oauth_token = oauth_token
|
||||
|
||||
account.save()
|
||||
return account
|
||||
|
||||
class IMAPAccountUpdateSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
oauth_token = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = IMAPAccount
|
||||
fields = ['webhook_url', 'password', 'oauth_token', 'is_active']
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
password = validated_data.pop('password', None)
|
||||
oauth_token = validated_data.pop('oauth_token', None)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
if password:
|
||||
instance.password = password
|
||||
if oauth_token:
|
||||
instance.oauth_token = oauth_token
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
class IMAPAccountSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = IMAPAccount
|
||||
fields = [
|
||||
'id', 'email', 'imap_server', 'imap_port', 'username',
|
||||
'auth_type', 'webhook_url', 'is_active', 'created_at',
|
||||
'updated_at', 'last_activity'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
class WebhookPayloadSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
event = serializers.CharField()
|
||||
message_id = serializers.CharField(required=False, allow_blank=True)
|
||||
subject = serializers.CharField(required=False, allow_blank=True)
|
||||
sender = serializers.CharField(required=False, allow_blank=True)
|
||||
timestamp = serializers.DateTimeField()
|
||||
10
src/relay/urls.py
Normal file
10
src/relay/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from relay.views import IMAPAccountViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'accounts', IMAPAccountViewSet, basename='imap-accounts')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
BIN
src/relay/utils/__pycache__/authentication.cpython-313.pyc
Normal file
BIN
src/relay/utils/__pycache__/authentication.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/utils/__pycache__/encryption.cpython-313.pyc
Normal file
BIN
src/relay/utils/__pycache__/encryption.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/utils/__pycache__/imap_manager.cpython-313.pyc
Normal file
BIN
src/relay/utils/__pycache__/imap_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/relay/utils/__pycache__/permissions.cpython-313.pyc
Normal file
BIN
src/relay/utils/__pycache__/permissions.cpython-313.pyc
Normal file
Binary file not shown.
48
src/relay/utils/authentication.py
Normal file
48
src/relay/utils/authentication.py
Normal file
@ -0,0 +1,48 @@
|
||||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class APITokenUser:
|
||||
"""A simple user-like object for API token authentication."""
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True
|
||||
|
||||
def __str__(self):
|
||||
return f"APITokenUser({self.token[:10]}...)"
|
||||
|
||||
class APITokenAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
auth_header = request.META.get('HTTP_AUTHORIZATION')
|
||||
|
||||
logger.info(f"Auth header received: '{auth_header}'")
|
||||
logger.info(f"Expected token: '{settings.API_TOKEN}'")
|
||||
|
||||
if not auth_header:
|
||||
logger.info("No Authorization header found")
|
||||
return None
|
||||
|
||||
if not auth_header.startswith('Bearer '):
|
||||
logger.error(f"Invalid authorization format: '{auth_header}'")
|
||||
raise exceptions.AuthenticationFailed('Invalid authorization format')
|
||||
|
||||
token = auth_header.replace('Bearer ', '')
|
||||
logger.info(f"Extracted token: '{token}'")
|
||||
|
||||
if not settings.API_TOKEN:
|
||||
logger.error("API_TOKEN setting is not configured")
|
||||
raise exceptions.AuthenticationFailed('Server configuration error')
|
||||
|
||||
if token != settings.API_TOKEN:
|
||||
logger.error(f"Token mismatch. Received: '{token}', Expected: '{settings.API_TOKEN}'")
|
||||
raise exceptions.AuthenticationFailed('Invalid API token')
|
||||
|
||||
logger.info("Authentication successful")
|
||||
# Return our custom API token user
|
||||
return (APITokenUser(token), token)
|
||||
22
src/relay/utils/encryption.py
Normal file
22
src/relay/utils/encryption.py
Normal file
@ -0,0 +1,22 @@
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from django.conf import settings
|
||||
|
||||
class CredentialEncryption:
|
||||
def __init__(self):
|
||||
key = settings.ENCRYPTION_KEY
|
||||
if not key:
|
||||
raise ValueError("ENCRYPTION_KEY setting not configured")
|
||||
self.fernet = Fernet(key.encode())
|
||||
|
||||
def encrypt(self, data: str) -> bytes:
|
||||
if not data:
|
||||
return b''
|
||||
return self.fernet.encrypt(data.encode())
|
||||
|
||||
def decrypt(self, encrypted_data: bytes) -> str:
|
||||
if not encrypted_data:
|
||||
return ''
|
||||
return self.fernet.decrypt(encrypted_data).decode()
|
||||
|
||||
encryption = CredentialEncryption()
|
||||
713
src/relay/utils/imap_manager.py
Normal file
713
src/relay/utils/imap_manager.py
Normal file
@ -0,0 +1,713 @@
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import imaplib
|
||||
import email
|
||||
import requests
|
||||
import socket
|
||||
import select
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from django.db import connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class IMAPConnectionManager:
|
||||
def __init__(self):
|
||||
self.connections = {} # email -> list of IMAPConnection instances
|
||||
self.running = False
|
||||
self.manager_thread = None
|
||||
|
||||
def start_manager(self):
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.manager_thread = threading.Thread(target=self._run_manager, daemon=True)
|
||||
self.manager_thread.start()
|
||||
logger.info("IMAP Connection Manager started")
|
||||
|
||||
def stop_manager(self):
|
||||
self.running = False
|
||||
for email_connections in list(self.connections.values()):
|
||||
for connection in email_connections:
|
||||
connection.stop()
|
||||
self.connections.clear()
|
||||
if self.manager_thread:
|
||||
self.manager_thread.join(timeout=5)
|
||||
logger.info("IMAP Connection Manager stopped")
|
||||
|
||||
def _run_manager(self):
|
||||
self._load_accounts()
|
||||
while self.running:
|
||||
try:
|
||||
self._health_check()
|
||||
time.sleep(60)
|
||||
except Exception as e:
|
||||
logger.error(f"Manager loop error: {e}")
|
||||
|
||||
def add_account(self, account):
|
||||
if account.email not in self.connections:
|
||||
self.connections[account.email] = []
|
||||
|
||||
# Get folders to monitor for this account
|
||||
folders = self._get_folders_to_monitor(account)
|
||||
|
||||
for folder in folders:
|
||||
connection = IMAPConnection(account, folder)
|
||||
self.connections[account.email].append(connection)
|
||||
connection.start()
|
||||
|
||||
logger.info(f"Added IMAP connections for {account.email} monitoring {len(folders)} folders: {folders}")
|
||||
|
||||
def remove_account(self, email):
|
||||
if email in self.connections:
|
||||
for connection in self.connections[email]:
|
||||
connection.stop()
|
||||
del self.connections[email]
|
||||
logger.info(f"Removed IMAP connections for {email}")
|
||||
|
||||
def update_account(self, email, updated_account):
|
||||
if email in self.connections:
|
||||
# Stop old connections
|
||||
for connection in self.connections[email]:
|
||||
connection.stop()
|
||||
del self.connections[email]
|
||||
# Start new connections
|
||||
self.add_account(updated_account)
|
||||
|
||||
def _get_folders_to_monitor(self, account):
|
||||
"""Get list of folders to monitor based on email provider."""
|
||||
|
||||
# Gmail
|
||||
if 'gmail.com' in account.imap_server.lower():
|
||||
return ['INBOX', '[Gmail]/Sent Mail']
|
||||
|
||||
# Outlook/Hotmail/Live
|
||||
elif any(provider in account.imap_server.lower() for provider in ['outlook', 'hotmail', 'live']):
|
||||
return ['INBOX', 'Sent Items']
|
||||
|
||||
# Yahoo
|
||||
elif 'yahoo' in account.imap_server.lower():
|
||||
return ['INBOX', 'Sent']
|
||||
|
||||
# iCloud
|
||||
elif 'icloud' in account.imap_server.lower():
|
||||
return ['INBOX', 'Sent Messages']
|
||||
|
||||
# Generic/Other providers - try common folder names
|
||||
else:
|
||||
return ['INBOX', 'Sent']
|
||||
|
||||
def _load_accounts(self):
|
||||
try:
|
||||
if not self._table_exists('imap_accounts'):
|
||||
logger.info("imap_accounts table does not exist yet, skipping account loading")
|
||||
return
|
||||
|
||||
from relay.models import IMAPAccount
|
||||
accounts = IMAPAccount.objects.filter(is_active=True)
|
||||
|
||||
for account in accounts:
|
||||
self.add_account(account)
|
||||
|
||||
logger.info(f"Loaded {len(accounts)} IMAP accounts")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading accounts: {e}")
|
||||
|
||||
def _table_exists(self, table_name):
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = %s
|
||||
);
|
||||
""", [table_name])
|
||||
return cursor.fetchone()[0]
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _health_check(self):
|
||||
if not self.connections:
|
||||
return
|
||||
|
||||
# Check all connections for all accounts
|
||||
accounts_to_restart = []
|
||||
for email, email_connections in list(self.connections.items()):
|
||||
unhealthy_connections = []
|
||||
for connection in email_connections:
|
||||
if not connection.is_healthy():
|
||||
unhealthy_connections.append(connection)
|
||||
|
||||
if unhealthy_connections:
|
||||
accounts_to_restart.append(email)
|
||||
|
||||
for email in accounts_to_restart:
|
||||
try:
|
||||
logger.warning(f"Restarting unhealthy connections for {email}")
|
||||
from relay.models import IMAPAccount
|
||||
account = IMAPAccount.objects.filter(email=email, is_active=True).first()
|
||||
if account:
|
||||
self.remove_account(email)
|
||||
self.add_account(account)
|
||||
else:
|
||||
self.remove_account(email)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during health check for {email}: {e}")
|
||||
|
||||
class IMAPConnection:
|
||||
def __init__(self, account, folder_name):
|
||||
self.account = account
|
||||
self.folder_name = folder_name
|
||||
self.imap = None
|
||||
self.running = False
|
||||
self.last_activity = datetime.now()
|
||||
self.reconnect_attempts = 0
|
||||
self.max_reconnects = 5
|
||||
self.connection_thread = None
|
||||
self.last_uid = None
|
||||
self._has_pending_messages = False
|
||||
|
||||
# Determine if this is an outgoing folder
|
||||
self.is_outgoing_folder = self._is_outgoing_folder(folder_name)
|
||||
|
||||
def _is_outgoing_folder(self, folder_name):
|
||||
"""Determine if this folder contains outgoing messages."""
|
||||
outgoing_keywords = ['sent', 'outbox', 'draft']
|
||||
return any(keyword in folder_name.lower() for keyword in outgoing_keywords)
|
||||
|
||||
def start(self):
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.connection_thread = threading.Thread(target=self._connection_loop, daemon=True)
|
||||
self.connection_thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.imap:
|
||||
try:
|
||||
self.imap.send(b'DONE\r\n')
|
||||
time.sleep(0.1)
|
||||
self.imap.close()
|
||||
self.imap.logout()
|
||||
except:
|
||||
pass
|
||||
self.imap = None
|
||||
if self.connection_thread:
|
||||
self.connection_thread.join(timeout=5)
|
||||
|
||||
def is_healthy(self):
|
||||
return (datetime.now() - self.last_activity).seconds < 2100
|
||||
|
||||
def _connection_loop(self):
|
||||
while self.running and self.reconnect_attempts < self.max_reconnects:
|
||||
try:
|
||||
self._connect_and_idle()
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP error for {self.account.email}[{self.folder_name}]: {e}")
|
||||
self.reconnect_attempts += 1
|
||||
if self.running:
|
||||
wait_time = min(60 * self.reconnect_attempts, 300)
|
||||
logger.info(f"Waiting {wait_time}s before reconnecting {self.account.email}[{self.folder_name}]")
|
||||
time.sleep(wait_time)
|
||||
|
||||
if self.reconnect_attempts >= self.max_reconnects:
|
||||
logger.error(f"Max reconnection attempts reached for {self.account.email}[{self.folder_name}]")
|
||||
self._update_account_status(False)
|
||||
|
||||
def _connect_and_idle(self):
|
||||
try:
|
||||
# Create IMAP connection
|
||||
self.imap = imaplib.IMAP4_SSL(self.account.imap_server, self.account.imap_port)
|
||||
|
||||
# Authenticate
|
||||
if self.account.password:
|
||||
self.imap.login(self.account.username, self.account.password)
|
||||
else:
|
||||
raise Exception("No password available for authentication")
|
||||
|
||||
# Robust folder selection with multiple strategies
|
||||
folder_selected = self._select_folder_with_fallback()
|
||||
if not folder_selected:
|
||||
logger.error(f"❌ Could not select any folder for {self.account.email}[{self.folder_name}], aborting connection")
|
||||
return
|
||||
|
||||
logger.info(f"✅ Connected to IMAP for {self.account.email}[{self.folder_name}]")
|
||||
self.reconnect_attempts = 0
|
||||
self._update_account_activity()
|
||||
|
||||
# Initialize last_uid if not set
|
||||
if self.last_uid is None:
|
||||
self._initialize_last_uid()
|
||||
|
||||
# IDLE loop
|
||||
while self.running:
|
||||
try:
|
||||
# Reset pending messages flag
|
||||
self._has_pending_messages = False
|
||||
|
||||
# Send IDLE command
|
||||
tag = self.imap._new_tag()
|
||||
command = f'{tag} IDLE\r\n'
|
||||
self.imap.send(command.encode())
|
||||
|
||||
# Read the continuation response (+ idling)
|
||||
response = self.imap.readline()
|
||||
logger.debug(f"IDLE response for {self.account.email}[{self.folder_name}]: {response}")
|
||||
|
||||
# Check for correct IDLE continuation response
|
||||
if b'+' not in response or b'idling' not in response.lower():
|
||||
logger.error(f"Unexpected IDLE response for {self.account.email}[{self.folder_name}]: {response}")
|
||||
break
|
||||
|
||||
logger.info(f"🔄 IDLE started successfully for {self.account.email}[{self.folder_name}]")
|
||||
|
||||
# Monitor for server notifications
|
||||
idle_start = time.time()
|
||||
while self.running and (time.time() - idle_start) < 1740: # 29 minutes
|
||||
try:
|
||||
# Check for incoming data with timeout
|
||||
ready = select.select([self.imap.sock], [], [], 30)
|
||||
|
||||
if ready[0]:
|
||||
# Data available - read it
|
||||
try:
|
||||
response = self.imap.readline()
|
||||
response_str = response.decode('utf-8', errors='ignore').strip()
|
||||
logger.debug(f"IDLE notification for {self.account.email}[{self.folder_name}]: {response_str}")
|
||||
|
||||
# Check for new message notification
|
||||
if 'EXISTS' in response_str:
|
||||
exists_count = self._parse_exists_response(response_str)
|
||||
message_type = "outgoing" if self.is_outgoing_folder else "incoming"
|
||||
logger.info(f"📧 New {message_type} message detected for {self.account.email}[{self.folder_name}] (total: {exists_count})")
|
||||
# Handle new messages and break out of IDLE loop
|
||||
self._handle_new_messages()
|
||||
break
|
||||
elif 'EXPUNGE' in response_str:
|
||||
logger.info(f"🗑️ Message deleted for {self.account.email}[{self.folder_name}]")
|
||||
elif 'FETCH' in response_str:
|
||||
logger.info(f"🏷️ Message flags changed for {self.account.email}[{self.folder_name}]")
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading IDLE response for {self.account.email}[{self.folder_name}]: {e}")
|
||||
continue
|
||||
else:
|
||||
# No data - continue monitoring
|
||||
logger.debug(f"⏰ IDLE timeout - continuing for {self.account.email}[{self.folder_name}]")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during IDLE monitoring for {self.account.email}[{self.folder_name}]: {e}")
|
||||
break
|
||||
|
||||
# End IDLE properly (if we haven't already)
|
||||
if not self._has_pending_messages:
|
||||
logger.info(f"⏹️ Ending IDLE naturally for {self.account.email}[{self.folder_name}]")
|
||||
self.imap.send(b'DONE\r\n')
|
||||
|
||||
# Read any completion responses
|
||||
try:
|
||||
done_response = self.imap.readline()
|
||||
logger.debug(f"IDLE completion response for {self.account.email}[{self.folder_name}]: {done_response}")
|
||||
# Clear any remaining responses
|
||||
response_count = 0
|
||||
while response_count < 5: # Limit to prevent infinite loop
|
||||
ready = select.select([self.imap.sock], [], [], 0.1)
|
||||
if ready[0]:
|
||||
response = self.imap.readline()
|
||||
logger.debug(f"Additional IDLE response for {self.account.email}[{self.folder_name}]: {response}")
|
||||
response_count += 1
|
||||
else:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading IDLE completion for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
self.last_activity = datetime.now()
|
||||
self._update_account_activity()
|
||||
|
||||
# Process any pending messages now
|
||||
if self._has_pending_messages:
|
||||
self._process_pending_messages()
|
||||
|
||||
# Brief pause before restarting IDLE
|
||||
if self.running:
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IDLE error for {self.account.email}[{self.folder_name}]: {e}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Connection error for {self.account.email}[{self.folder_name}]: {e}")
|
||||
raise
|
||||
|
||||
def _select_folder_with_fallback(self):
|
||||
"""
|
||||
Robust folder selection with multiple strategies and fallbacks.
|
||||
Returns True if a folder was successfully selected, False otherwise.
|
||||
"""
|
||||
logger.info(f"🔍 Attempting to select folder '{self.folder_name}' for {self.account.email}")
|
||||
|
||||
# Strategy 1: Try the folder name as-is
|
||||
if self._try_select_folder(self.folder_name):
|
||||
return True
|
||||
|
||||
# Strategy 2: For Gmail folders, try different quoting methods
|
||||
if '[Gmail]' in self.folder_name:
|
||||
gmail_strategies = [
|
||||
f'"{self.folder_name}"', # Full quoted
|
||||
self.folder_name.replace('[Gmail]/', ''), # Remove [Gmail]/ prefix
|
||||
f'"[Gmail]"/"{self.folder_name.split("/")[1]}"', # Quote parts separately
|
||||
self.folder_name.replace(' ', '\\ '), # Escape spaces
|
||||
]
|
||||
|
||||
for strategy in gmail_strategies:
|
||||
logger.debug(f"Trying Gmail strategy: {strategy}")
|
||||
if self._try_select_folder(strategy):
|
||||
self.folder_name = strategy # Update to working name
|
||||
return True
|
||||
|
||||
# Strategy 3: Try with different quoting
|
||||
quoting_strategies = [
|
||||
f'"{self.folder_name}"', # Add quotes
|
||||
self.folder_name.replace('"', ''), # Remove quotes
|
||||
self.folder_name.replace(' ', '\\ '), # Escape spaces
|
||||
]
|
||||
|
||||
for strategy in quoting_strategies:
|
||||
if strategy != self.folder_name: # Don't repeat what we already tried
|
||||
logger.debug(f"Trying quoting strategy: {strategy}")
|
||||
if self._try_select_folder(strategy):
|
||||
self.folder_name = strategy
|
||||
return True
|
||||
|
||||
# Strategy 4: Try alternative folder names
|
||||
logger.info(f"⚠️ Primary folder selection failed, trying alternatives for {self.account.email}")
|
||||
alternative_folders = self._get_alternative_folder_names()
|
||||
|
||||
for alt_folder in alternative_folders[:5]: # Limit to first 5 alternatives
|
||||
logger.debug(f"Trying alternative folder: {alt_folder}")
|
||||
if self._try_select_folder(alt_folder):
|
||||
logger.info(f"✅ Using alternative folder '{alt_folder}' instead of '{self.folder_name}' for {self.account.email}")
|
||||
self.folder_name = alt_folder
|
||||
return True
|
||||
|
||||
logger.error(f"❌ All folder selection strategies failed for {self.account.email}")
|
||||
return False
|
||||
|
||||
def _try_select_folder(self, folder_name):
|
||||
"""
|
||||
Try to select a specific folder name.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
result, data = self.imap.select(folder_name)
|
||||
if result == 'OK':
|
||||
message_count = int(data[0]) if data and data[0] else 0
|
||||
logger.info(f"✅ Successfully selected folder '{folder_name}' with {message_count} messages")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"❌ Failed to select '{folder_name}': {result} - {data}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Exception selecting '{folder_name}': {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _get_alternative_folder_names(self):
|
||||
"""Get alternative folder names to try if the primary folder doesn't exist."""
|
||||
if self.is_outgoing_folder:
|
||||
return ['Sent', 'Sent Items', 'Sent Messages', '[Gmail]/Sent Mail', 'INBOX.Sent']
|
||||
else:
|
||||
return ['INBOX']
|
||||
|
||||
def _initialize_last_uid(self):
|
||||
"""Initialize last_uid to the latest message UID to avoid processing old messages."""
|
||||
try:
|
||||
result, messages = self.imap.uid('search', None, 'ALL')
|
||||
|
||||
if result == 'OK' and messages[0]:
|
||||
uid_list = messages[0].split()
|
||||
if uid_list:
|
||||
self.last_uid = uid_list[-1].decode() if isinstance(uid_list[-1], bytes) else str(uid_list[-1])
|
||||
logger.info(f"Initialized last_uid for {self.account.email}[{self.folder_name}]: {self.last_uid}")
|
||||
else:
|
||||
self.last_uid = "0"
|
||||
else:
|
||||
self.last_uid = "0"
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing last_uid for {self.account.email}[{self.folder_name}]: {e}")
|
||||
self.last_uid = "0"
|
||||
|
||||
def _parse_exists_response(self, response_str):
|
||||
"""Parse EXISTS response to get the message count."""
|
||||
try:
|
||||
parts = response_str.split()
|
||||
for i, part in enumerate(parts):
|
||||
if part == 'EXISTS' and i > 0:
|
||||
return int(parts[i-1])
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing EXISTS response '{response_str}': {e}")
|
||||
return None
|
||||
|
||||
def _handle_new_messages(self):
|
||||
"""Handle new messages detected via IDLE - end IDLE to process immediately."""
|
||||
try:
|
||||
message_type = "outgoing" if self.is_outgoing_folder else "incoming"
|
||||
logger.info(f"New {message_type} messages detected for {self.account.email}[{self.folder_name}] - ending IDLE to process")
|
||||
self._has_pending_messages = True
|
||||
|
||||
# End IDLE early so we can process messages immediately
|
||||
try:
|
||||
self.imap.send(b'DONE\r\n')
|
||||
logger.debug(f"Sent DONE to end IDLE early for {self.account.email}[{self.folder_name}]")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error sending DONE for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling new messages for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
def _process_pending_messages(self):
|
||||
"""Process any pending new messages after IDLE has ended."""
|
||||
if not self._has_pending_messages:
|
||||
return
|
||||
|
||||
try:
|
||||
message_type = "outgoing" if self.is_outgoing_folder else "incoming"
|
||||
logger.info(f"Processing pending {message_type} messages for {self.account.email}[{self.folder_name}]")
|
||||
new_messages = self._get_new_messages_since_last()
|
||||
|
||||
for message_details in new_messages:
|
||||
self._send_webhook_for_message(message_details)
|
||||
|
||||
self._has_pending_messages = False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing pending messages for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
def _get_new_messages_since_last(self):
|
||||
"""Get only new messages since the last check."""
|
||||
try:
|
||||
if self.last_uid and self.last_uid != "0":
|
||||
search_criteria = f'UID {int(self.last_uid)+1}:*'
|
||||
result, messages = self.imap.uid('search', None, search_criteria)
|
||||
else:
|
||||
result, messages = self.imap.uid('search', None, 'ALL')
|
||||
|
||||
if result != 'OK' or not messages[0]:
|
||||
return []
|
||||
|
||||
new_uids = messages[0].split()
|
||||
if not new_uids:
|
||||
return []
|
||||
|
||||
# Limit to last 5 new messages to avoid webhook spam
|
||||
recent_uids = new_uids[-5:] if len(new_uids) > 5 else new_uids
|
||||
|
||||
new_messages = []
|
||||
for uid in recent_uids:
|
||||
details = self._get_message_details_by_uid(uid)
|
||||
if details:
|
||||
new_messages.append(details)
|
||||
|
||||
# Update last seen UID
|
||||
if new_uids:
|
||||
self.last_uid = new_uids[-1].decode() if isinstance(new_uids[-1], bytes) else str(new_uids[-1])
|
||||
logger.debug(f"Updated last_uid for {self.account.email}[{self.folder_name}]: {self.last_uid}")
|
||||
|
||||
logger.info(f"Found {len(new_messages)} new messages for {self.account.email}[{self.folder_name}]")
|
||||
return new_messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting new messages for {self.account.email}[{self.folder_name}]: {e}")
|
||||
return []
|
||||
|
||||
def _get_message_details_by_uid(self, uid):
|
||||
"""Fetch message details by UID including body."""
|
||||
try:
|
||||
result, msg_data = self.imap.uid('fetch', uid, '(RFC822)')
|
||||
if result != 'OK' or not msg_data or not msg_data[0]:
|
||||
return None
|
||||
|
||||
raw_email = msg_data[0][1]
|
||||
email_message = email.message_from_bytes(raw_email)
|
||||
|
||||
# Extract the body
|
||||
body_text = self._extract_body(email_message)
|
||||
|
||||
details = {
|
||||
"message_id": uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
"uid": uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
"subject": self._decode_header(email_message.get('Subject', '')),
|
||||
"sender": self._decode_header(email_message.get('From', '')),
|
||||
"recipient": self._decode_header(email_message.get('To', '')),
|
||||
"date": email_message.get('Date', ''),
|
||||
"message_id_header": email_message.get('Message-ID', ''),
|
||||
"body": body_text,
|
||||
"content_type": email_message.get_content_type(),
|
||||
"is_multipart": email_message.is_multipart(),
|
||||
}
|
||||
|
||||
logger.debug(f"Fetched message UID {uid} for {self.account.email}[{self.folder_name}]: {details['subject']}")
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching message UID {uid} for {self.account.email}[{self.folder_name}]: {e}")
|
||||
return None
|
||||
|
||||
def _extract_body(self, email_message):
|
||||
"""Extract the text body from an email message."""
|
||||
body = ""
|
||||
|
||||
try:
|
||||
if email_message.is_multipart():
|
||||
# Handle multipart messages (most common)
|
||||
for part in email_message.walk():
|
||||
content_type = part.get_content_type()
|
||||
content_disposition = str(part.get("Content-Disposition", ""))
|
||||
|
||||
# Skip attachments
|
||||
if "attachment" in content_disposition:
|
||||
continue
|
||||
|
||||
# Get text parts
|
||||
if content_type == "text/plain":
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
part_body = part.get_payload(decode=True)
|
||||
if part_body:
|
||||
body += part_body.decode(charset, errors='replace') + "\n"
|
||||
elif content_type == "text/html" and not body:
|
||||
# Use HTML as fallback if no plain text
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
part_body = part.get_payload(decode=True)
|
||||
if part_body:
|
||||
html_body = part_body.decode(charset, errors='replace')
|
||||
# Simple HTML to text conversion
|
||||
body = self._html_to_text(html_body)
|
||||
else:
|
||||
# Handle single-part messages
|
||||
content_type = email_message.get_content_type()
|
||||
if content_type in ["text/plain", "text/html"]:
|
||||
charset = email_message.get_content_charset() or 'utf-8'
|
||||
payload = email_message.get_payload(decode=True)
|
||||
if payload:
|
||||
if content_type == "text/html":
|
||||
body = self._html_to_text(payload.decode(charset, errors='replace'))
|
||||
else:
|
||||
body = payload.decode(charset, errors='replace')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting email body: {e}")
|
||||
body = "[Error extracting message body]"
|
||||
|
||||
return body.strip()
|
||||
|
||||
def _html_to_text(self, html):
|
||||
"""Convert HTML to plain text (basic conversion)."""
|
||||
try:
|
||||
import re
|
||||
|
||||
# Remove HTML tags
|
||||
text = re.sub('<[^<]+?>', '', html)
|
||||
|
||||
# Decode HTML entities
|
||||
import html as html_module
|
||||
text = html_module.unescape(text)
|
||||
|
||||
# Clean up whitespace
|
||||
text = re.sub(r'\n\s*\n', '\n\n', text) # Multiple newlines to double
|
||||
text = re.sub(r'[ \t]+', ' ', text) # Multiple spaces to single
|
||||
|
||||
return text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting HTML to text: {e}")
|
||||
return html # Return original HTML if conversion fails
|
||||
|
||||
def _decode_header(self, header_value):
|
||||
"""Decode email header values that might be encoded."""
|
||||
if not header_value:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from email.header import decode_header
|
||||
decoded_parts = decode_header(header_value)
|
||||
decoded_string = ""
|
||||
|
||||
for part, encoding in decoded_parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded_string += part.decode(encoding or 'utf-8', errors='replace')
|
||||
else:
|
||||
decoded_string += part
|
||||
|
||||
return decoded_string.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error decoding header '{header_value}': {e}")
|
||||
return str(header_value).strip()
|
||||
|
||||
def _send_webhook_for_message(self, message_details):
|
||||
"""Send webhook notification for a specific message."""
|
||||
try:
|
||||
# Determine event type based on folder
|
||||
event_type = "sent_message" if self.is_outgoing_folder else "new_message"
|
||||
|
||||
webhook_data = {
|
||||
"email": self.account.email,
|
||||
"event": event_type,
|
||||
"folder": self.folder_name,
|
||||
"message_id": message_details.get("message_id", "unknown"),
|
||||
"uid": message_details.get("uid", "unknown"),
|
||||
"subject": message_details.get("subject", ""),
|
||||
"sender": message_details.get("sender", ""),
|
||||
"recipient": message_details.get("recipient", ""),
|
||||
"body": message_details.get("body", ""),
|
||||
"content_type": message_details.get("content_type", ""),
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"message_timestamp": message_details.get("date", ""),
|
||||
"message_id_header": message_details.get("message_id_header", ""),
|
||||
"is_outgoing": self.is_outgoing_folder,
|
||||
}
|
||||
|
||||
self._send_webhook(webhook_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending webhook for message {message_details.get('uid', 'unknown')} for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
def _send_webhook(self, data):
|
||||
try:
|
||||
response = requests.post(
|
||||
self.account.webhook_url,
|
||||
json=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
direction = "outgoing" if data.get('is_outgoing') else "incoming"
|
||||
if response.status_code == 200:
|
||||
logger.info(f"✅ {direction.title()} webhook sent successfully for {self.account.email}[{self.folder_name}]: {data.get('subject', 'Unknown subject')}")
|
||||
else:
|
||||
logger.error(f"❌ {direction.title()} webhook failed for {self.account.email}[{self.folder_name}]: HTTP {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Webhook error for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
def _update_account_activity(self):
|
||||
try:
|
||||
from relay.models import IMAPAccount
|
||||
IMAPAccount.objects.filter(email=self.account.email).update(
|
||||
last_activity=timezone.now()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating activity for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
def _update_account_status(self, is_active):
|
||||
try:
|
||||
from relay.models import IMAPAccount
|
||||
IMAPAccount.objects.filter(email=self.account.email).update(
|
||||
is_active=is_active
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating status for {self.account.email}[{self.folder_name}]: {e}")
|
||||
|
||||
# Global connection manager instance
|
||||
connection_manager = IMAPConnectionManager()
|
||||
15
src/relay/utils/permissions.py
Normal file
15
src/relay/utils/permissions.py
Normal file
@ -0,0 +1,15 @@
|
||||
from rest_framework import permissions
|
||||
from relay.utils.authentication import APITokenUser
|
||||
|
||||
class HasValidAPIToken(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission to only allow access to API token authenticated users.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Check if user is authenticated via API token
|
||||
return (
|
||||
request.user and
|
||||
isinstance(request.user, APITokenUser) and
|
||||
request.user.is_authenticated
|
||||
)
|
||||
101
src/relay/views.py
Normal file
101
src/relay/views.py
Normal file
@ -0,0 +1,101 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action, api_view, authentication_classes, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import BasePermission
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from relay.models import IMAPAccount
|
||||
from relay.serializers import (
|
||||
IMAPAccountSerializer,
|
||||
IMAPAccountCreateSerializer,
|
||||
IMAPAccountUpdateSerializer
|
||||
)
|
||||
from relay.utils.imap_manager import connection_manager
|
||||
from relay.utils.authentication import APITokenAuthentication, APITokenUser
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class HasValidAPIToken(BasePermission):
|
||||
"""Custom permission for API token authentication."""
|
||||
def has_permission(self, request, view):
|
||||
return (
|
||||
request.user and
|
||||
isinstance(request.user, APITokenUser) and
|
||||
getattr(request.user, 'is_authenticated', False)
|
||||
)
|
||||
|
||||
class IMAPAccountViewSet(viewsets.ModelViewSet):
|
||||
queryset = IMAPAccount.objects.all()
|
||||
authentication_classes = [APITokenAuthentication]
|
||||
permission_classes = [HasValidAPIToken]
|
||||
lookup_field = 'email'
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return IMAPAccountCreateSerializer
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
return IMAPAccountUpdateSerializer
|
||||
return IMAPAccountSerializer
|
||||
|
||||
def create(self, request):
|
||||
logger.info(f"Create request from user: {request.user}")
|
||||
logger.info(f"User type: {type(request.user)}")
|
||||
logger.info(f"Is authenticated: {getattr(request.user, 'is_authenticated', False)}")
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
account = serializer.save()
|
||||
|
||||
# Add to connection manager
|
||||
connection_manager.add_account(account)
|
||||
|
||||
response_serializer = IMAPAccountSerializer(account)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def update(self, request, email=None):
|
||||
account = get_object_or_404(IMAPAccount, email=email)
|
||||
serializer = self.get_serializer(account, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
updated_account = serializer.save()
|
||||
|
||||
# Update connection manager
|
||||
connection_manager.update_account(email, updated_account)
|
||||
|
||||
response_serializer = IMAPAccountSerializer(updated_account)
|
||||
return Response(response_serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, email=None):
|
||||
account = get_object_or_404(IMAPAccount, email=email)
|
||||
|
||||
# Remove from connection manager
|
||||
connection_manager.remove_account(email)
|
||||
|
||||
account.delete()
|
||||
return Response(
|
||||
{"detail": f"Account {email} unregistered successfully"},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def health(self, request):
|
||||
return Response({
|
||||
"status": "healthy",
|
||||
"active_connections": len(connection_manager.connections),
|
||||
"manager_running": connection_manager.running
|
||||
})
|
||||
|
||||
@api_view(['GET'])
|
||||
@authentication_classes([APITokenAuthentication])
|
||||
@permission_classes([HasValidAPIToken])
|
||||
def test_auth(request):
|
||||
return Response({
|
||||
"message": "Authentication successful!",
|
||||
"user": str(request.user),
|
||||
"user_type": type(request.user).__name__,
|
||||
"is_authenticated": getattr(request.user, 'is_authenticated', False),
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
11
start_dev.sh
Normal file
11
start_dev.sh
Normal file
@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
echo "Starting Django..."
|
||||
python src/manage.py runserver 8009 &
|
||||
|
||||
echo "Waiting for Django to start..."
|
||||
sleep 5
|
||||
|
||||
echo "Starting IMAP connections..."
|
||||
python src/manage.py start_imap_connections
|
||||
|
||||
wait
|
||||
Loading…
Reference in New Issue
Block a user