From 15aa874800a28184f6b1c0e1f72087b4e1758843 Mon Sep 17 00:00:00 2001 From: DJ Gillespie Date: Tue, 23 Sep 2025 19:01:30 -0600 Subject: [PATCH] initial setup --- pythonechoserver.py | 89 +++ requirements.txt | 11 + src/core/__init__.py | 0 src/core/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 150 bytes src/core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 142 bytes src/core/__pycache__/settings.cpython-311.pyc | Bin 0 -> 2531 bytes src/core/__pycache__/urls.cpython-311.pyc | Bin 0 -> 1022 bytes src/core/__pycache__/urls.cpython-313.pyc | Bin 0 -> 1077 bytes src/core/__pycache__/wsgi.cpython-311.pyc | Bin 0 -> 671 bytes src/core/__pycache__/wsgi.cpython-313.pyc | Bin 0 -> 629 bytes src/core/asgi.py | 16 + src/core/db.sqlite3 | Bin 0 -> 139264 bytes src/core/settings/__init__.py | 175 +++++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 2544 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 3891 bytes src/core/urls.py | 22 + src/core/wsgi.py | 16 + src/manage.py | 22 + src/relay/__pycache__/admin.cpython-313.pyc | Bin 0 -> 1122 bytes src/relay/__pycache__/apps.cpython-313.pyc | Bin 0 -> 1384 bytes src/relay/__pycache__/models.cpython-313.pyc | Bin 0 -> 3672 bytes .../__pycache__/serializers.cpython-313.pyc | Bin 0 -> 3969 bytes src/relay/__pycache__/urls.cpython-313.pyc | Bin 0 -> 546 bytes src/relay/__pycache__/views.cpython-313.pyc | Bin 0 -> 6045 bytes src/relay/admin.py | 25 + src/relay/apps.py | 23 + src/relay/management/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 154 bytes src/relay/management/commands/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 163 bytes .../__pycache__/list_folders.cpython-313.pyc | Bin 0 -> 2865 bytes .../start_imap_connections.cpython-313.pyc | Bin 0 -> 4948 bytes src/relay/management/commands/list_folders.py | 43 ++ .../commands/start_imap_connections.py | 76 ++ src/relay/migrations/0001_initial.py | 36 + src/relay/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-313.pyc | Bin 0 -> 1978 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 154 bytes src/relay/models.py | 63 ++ src/relay/serializers.py | 68 ++ src/relay/urls.py | 10 + .../authentication.cpython-313.pyc | Bin 0 -> 3245 bytes .../__pycache__/encryption.cpython-313.pyc | Bin 0 -> 1595 bytes .../__pycache__/imap_manager.cpython-313.pyc | Bin 0 -> 43062 bytes .../__pycache__/permissions.cpython-313.pyc | Bin 0 -> 1035 bytes src/relay/utils/authentication.py | 48 ++ src/relay/utils/encryption.py | 22 + src/relay/utils/imap_manager.py | 713 ++++++++++++++++++ src/relay/utils/permissions.py | 15 + src/relay/views.py | 101 +++ start_dev.sh | 11 + 51 files changed, 1605 insertions(+) create mode 100644 pythonechoserver.py create mode 100644 requirements.txt create mode 100644 src/core/__init__.py create mode 100644 src/core/__pycache__/__init__.cpython-311.pyc create mode 100644 src/core/__pycache__/__init__.cpython-313.pyc create mode 100644 src/core/__pycache__/settings.cpython-311.pyc create mode 100644 src/core/__pycache__/urls.cpython-311.pyc create mode 100644 src/core/__pycache__/urls.cpython-313.pyc create mode 100644 src/core/__pycache__/wsgi.cpython-311.pyc create mode 100644 src/core/__pycache__/wsgi.cpython-313.pyc create mode 100644 src/core/asgi.py create mode 100644 src/core/db.sqlite3 create mode 100644 src/core/settings/__init__.py create mode 100644 src/core/settings/__pycache__/__init__.cpython-311.pyc create mode 100644 src/core/settings/__pycache__/__init__.cpython-313.pyc create mode 100644 src/core/urls.py create mode 100644 src/core/wsgi.py create mode 100755 src/manage.py create mode 100644 src/relay/__pycache__/admin.cpython-313.pyc create mode 100644 src/relay/__pycache__/apps.cpython-313.pyc create mode 100644 src/relay/__pycache__/models.cpython-313.pyc create mode 100644 src/relay/__pycache__/serializers.cpython-313.pyc create mode 100644 src/relay/__pycache__/urls.cpython-313.pyc create mode 100644 src/relay/__pycache__/views.cpython-313.pyc create mode 100644 src/relay/admin.py create mode 100644 src/relay/apps.py create mode 100644 src/relay/management/__init__.py create mode 100644 src/relay/management/__pycache__/__init__.cpython-313.pyc create mode 100644 src/relay/management/commands/__init__.py create mode 100644 src/relay/management/commands/__pycache__/__init__.cpython-313.pyc create mode 100644 src/relay/management/commands/__pycache__/list_folders.cpython-313.pyc create mode 100644 src/relay/management/commands/__pycache__/start_imap_connections.cpython-313.pyc create mode 100644 src/relay/management/commands/list_folders.py create mode 100644 src/relay/management/commands/start_imap_connections.py create mode 100644 src/relay/migrations/0001_initial.py create mode 100644 src/relay/migrations/__init__.py create mode 100644 src/relay/migrations/__pycache__/0001_initial.cpython-313.pyc create mode 100644 src/relay/migrations/__pycache__/__init__.cpython-313.pyc create mode 100644 src/relay/models.py create mode 100644 src/relay/serializers.py create mode 100644 src/relay/urls.py create mode 100644 src/relay/utils/__pycache__/authentication.cpython-313.pyc create mode 100644 src/relay/utils/__pycache__/encryption.cpython-313.pyc create mode 100644 src/relay/utils/__pycache__/imap_manager.cpython-313.pyc create mode 100644 src/relay/utils/__pycache__/permissions.cpython-313.pyc create mode 100644 src/relay/utils/authentication.py create mode 100644 src/relay/utils/encryption.py create mode 100644 src/relay/utils/imap_manager.py create mode 100644 src/relay/utils/permissions.py create mode 100644 src/relay/views.py create mode 100644 start_dev.sh diff --git a/pythonechoserver.py b/pythonechoserver.py new file mode 100644 index 0000000..39b4e85 --- /dev/null +++ b/pythonechoserver.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..17e2eaf --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/__pycache__/__init__.cpython-311.pyc b/src/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efd714ebea696726495907e90baa2136f93edeba GIT binary patch literal 150 zcmZ3^%ge<81hI0LGeGoX5CH>>P{wCAAY(d13PUi1CZpd;kQfq}VxW^Q6Zd{JsnVx@j^eo?A^e0*kJW=VX!UP0wA4x8Nkl+v73yCPPgPLSEf U{6OLZGb1D82L>2X#0(Sz0K;M+M*si- literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae8049759246ed93f37d99bb8658a67d2d48a8f3 GIT binary patch literal 142 zcmey&%ge<81hI0LGeGoX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iZenx(7s(wmV zy1s#dxqfDDVnKXSYEELMesNK R>BS(%M`lJw#v*1Q3jhY(ABzA0 literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/settings.cpython-311.pyc b/src/core/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f63182a25632c5755f75ea9edc8937cb7f58aa36 GIT binary patch literal 2531 zcmb6bTW`~5)Xx3hG^9{qnp{CCfz_du0h&;UNlb}IldMkZ=7(8MzORW}UxNLGQa|N! zllHK;?SY4hhh4P&n6e?Q@>8WfP1=Jc-gw!Lb7`{_X`N#qpYPny`Of*AACk!!1=p+h ze{268r>H-?#pntRkvD$<@(qP3kwP@`skG)3eKh5xun+scrzz@Zcm~&Zfe|+TM*d#{ z|CZYhj`+uz7(ziYjQnDxMTyZCg+ecBF$V82yyGY$CXh$r;ya=>tF^Q65 z3Z>8#4xzLSS)l0`K7|&iPzId=sSL{Y;Y=T%Rp?egOrvw+G@2DN;O`7PS$Jm9dGRc| zAW*rB@*<=ZD{jkr%U~=_h@!VFX3H=cMUy*{iB)-z>6pegZW1;YTg5syWr7jY*kcw@ zUu{uFnxZonkxep`TwuBu7-EKYm)RxIeF#Q1!^DiDgMCdVilH-blePjL6%~H~0pM7h zkdF0HK97v1#ST1-{5K6PkM%s5%M+ucG_Cw7;{3ooX=7%qtE!BuScEaQjx&;V1dgy` zGS6hSi>?3T%fwq_j{fvEG)FsunoQbtKtdKVl_s%oqRTta<$0Z4B1;5axqSKN{Ik|| z+`KYBcY8~}c2!Yw_jjJ>b{lsrZRzgv=M6>6h3xDgMYd_^V5Y(Li)o*kq;^T$zBobj zRIpCSUI$zDxe2C)Eei^KlA!@ZZh^*yNeu#9()2dM%7yL4Ay-onQt_^AV%EaVuBnhc zRv02{M?Cx1q_LrUc1&Lw(8jcKC!N3^YlhAi`mg^GwahKH0N{y&cgIBUOvEBX@y6re zU=4Lsn*A0WQ=CjmlMYz#z z+1E}XY2v3aD8#-#ls)QDubl6&?3LMGo)A7N;X4#Yyj;KuKi~?F`AX3V6ie%Z6X9w# zsgH%LwR)*q5$u_MwRWwR!pfZv)VIHRP8i`WxvP?+Zbpsc;#p5sh2Uj72E-wr^En~D zvRbO}PN2fA@n!<1*?u$;b-!aevSsZWCX(R9P*C3;yJ2CIBi^8P2^JJhQDtu;o(v=m z(_T5Pr8PxYw60dhdW*D=^*=tX{$N0A!x)bZ+&`^>N>{^hZk@Cc#QI{_a{L?hf*r}j zu?~}xujI}-VM&5RS(2P^2aY~fX_$T}o)eKI@6hn*CbkUq8FoS)I4^YKL>_Pg?`@8|Q37A#ruVk64tZs0tyi}+bc`#U7<0Y{QO%d6EfJ;kvD+8>&bfe;gJX&3Z9O~es z;KlJ1^~U=Qyv^m?hK6B$TlpI|?&OcZnR%~WkG>jgXYXy~o@St~idW19(DeRDSUf=2 zPt&yPi_rm>^1v)bpWmlu$8R@8CDLEq`a1IE%2zAD$LD^H&;6L)kI%h|v#-7T!`Jb9 zy@;ElQV-}}45XvUUhpuT=_TD*IOB7v5$L7dOe6(_5di5dmA=?ZxDhJL(+BCyL3Z{q z!MIfLQfdtN!`YY{O$0+OFY&ZhRR;wr_$g!m^#R?2kF^w@4ZSdxIup!1U=vn z_609c(W(8k==y!29O*A=L literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/urls.cpython-311.pyc b/src/core/__pycache__/urls.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..691d946b8e7b9f3bb979d0048cade5dee4f1d12c GIT binary patch literal 1022 zcma)5&ui2`6rNGGS&`T~9rF z^ic3WP{esK+#Bgl`G&T~y zO89FX;$5k*kc#6Z8%QNe2%M*!YK}GMJs-mw8I$WCAxy?PWMi5QWv=9y$0m%WAUq>C z!YyKCp2V71X9DRp@gKgS0I1#y2ribxBNrA;QqIq6j zF8FU({$+6S57+XO79}8(lTv;Mv zDO6485nz?<&&kI6hg3%vq!awZXfKP?2}`meh*0LUrNz+9TA#gA7z|XPJ=T|Z0z9as z?9Jtxw|TBBh1nyd4mDFl1SYg&?fp z2QW*yfQ$|W;VOYcLE-z9eBeIZq73Gg5ttTz%5q%-mKnjEKu$>DKzRm2lsrHvrV;n! zLtG@ZWb}|k!jEW7?7h|98ePQ*X1<27YTV1l7cm1GBtuDew88ql09!2;*xnX7Eqb2c-~ z`EpMZizll74+Rwi!W0l+3)R4RC2MK)*F>Y@eau5uWYPNv(RLAK!#FJxDT3~&s6}#^ zjXv927$~90@u*m`=)rz1WiXa&JPuz8-hixQm$WF?M%&A1f~0^=Cs#RiUDxxR-)bE_ zqq1?Fr-f%$mQuWG*U`sQ+K-McT&C#Q(aWim-+0qiI}GLOgkd#Tqm?8DOM6USG*Hb< zP+*qyFk87To(fV~idZ!>G3@W)QvL>&htyzEe3+n>m8a^|Tj-ZZRcAe? z@h~IL*}TkDH;b>!$9c;zj0RZw#{Hk*;l&iNr%&#DSvw0(gR_m(jgj@>ht)Z` Zb$a{T-ONl-U7Y#lygmij6F=eNA8Z-lgCN|tdj|)NX1(RV-rs%mYVTwzVUSSp3kz1sWP7h)S+<`n-DUw%A_=b!rcMloZc+j7{+OcO>5nV#hE zFo!YZWXu$wl>{pwCH+1t%bX|7a4GtI*YBkhDx?C!h0`o%1tNuO&NUhE93Bu25Dbke z^%F`{ndmSbF_FoIr$iPM1bq~4(V;vwlBQ7Ra#DaW^i*e@27juT_S)Fq&A`OfS$xfg zUEkSz(d!(<(eBYvx7Uf{*9SXqUhSSW6Xz?`UKL-h%r}E*uW7}7_gJv*v2)vk7!!iv2EwJ0Gf_6y1Dt7E{9FEN@&$}<~}SZcJ=9e zrq5K%G2K@|n!4q$k1_t|)p2cl2jSp1+WLXKi{L!?hSt8Iwa@F}r(0jq^PjDa%hvt# JhPvrE{{c=#wBY~% literal 0 HcmV?d00001 diff --git a/src/core/asgi.py b/src/core/asgi.py new file mode 100644 index 0000000..658a8e6 --- /dev/null +++ b/src/core/asgi.py @@ -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() diff --git a/src/core/db.sqlite3 b/src/core/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..e7b91bbde73e35775f77081c49cd6aa9bc759441 GIT binary patch literal 139264 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 Y0w4eaAOHd&@NOq?+A9g|F^^;a0~cV~WdHyG literal 0 HcmV?d00001 diff --git a/src/core/settings/__init__.py b/src/core/settings/__init__.py new file mode 100644 index 0000000..2042bca --- /dev/null +++ b/src/core/settings/__init__.py @@ -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=') \ No newline at end of file diff --git a/src/core/settings/__pycache__/__init__.cpython-311.pyc b/src/core/settings/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6247480aee10bd7b1ca4e7663d66013b6b5d5392 GIT binary patch literal 2544 zcmb6bTW`}=*v|dlG^9{q(p*6)fz_du0h&;Uo0t-jCRv@*%@4DjoYTauFTp;c)K7Wb zq)FS`_Q1omhh4P&n6e?Q@>8WfP1=Jc-gwzQ=h9{=(wJj^{?2zl-{m+zB$F`;t}oyH zt^IeLqW56M%efp`F{!g zTW%*f;-6q*2nEG3@{5rcB}Q8m3Vlb5F?fgJ9Y+x{fjp`od9?Us zPzp`q5K7yy3pD-Qr_kaQ%AhkKl|k7)oaw`}3f&5bX>?AUMzdlD{GEX(3(pKXFP=pg z1S)q?UW6^hiaWC2G8hXJqUbG)*)~i@(d3R~VpZN}I;OFMn}m(U*07FEnP9{;_L&9L zS6h^krs#}CWRnaf7nrUEhM1w%hy|4l>7V?7V%^2F#UO)Ec&I6p8?+L+nysw$%@7GaF-lO4%A0!LUe znWwVa#n%7vW#a8IM}K++&CyPvCX;p@kdQ@8rAh3Y=<@C}d0r=%$Pz(UE?>Sm|Fm@- zH?Pdk-QL!(T~(Ca{oQA|y~Z6&Te`daX+zO+Av-(RBHJ`{FwAxy#mP;KQZ=r;K1kn~*6lZJLRFf(J92XuhJgL}fhlXa+VT3`8q`z*K$hMqJzxL0F1f$V}hz!%p1?;PnW=>172sgSd z``TMbn)nF}3bC&bWsf`5OXnLbdug_pCxnkv_y&a$FBfpat6bp`Unx3)VrfHgB3!K| z^|5fZRxed6f<4pE)}GZ;Sh>@I{Pv6Igc07ByDB;EX4E)Ip7lgk2wt{hKpf&ZpA+IM zYo!YB1S;G*Zzf=x?S~Ul_dBK|Th^XoA_-0m1@+yrn-(@X;tgt-U_sFoRrV(0@j${b z?UlE+w65ri*44^bZ;|$i{)ca?KNyhOFvb%D_utk)rK@2$w@zCKVtuh|IsVOh!H(qN zScgf;S90f^uq45uEJ;qd14o~#G)zAv&xuHqcW8KY6I+J*6g!~~oEJKABCDLhdxdmj z0$(V3s6DL^YS2x$3Bv&q1xA>y;uqxCACsnElbszx&sxLUP^-{4|=C`;F-brj! zt95B}qg<#~9yrlDzg{bIbzX4NTf$n&lP{MFoYyE$w8+&t&#mCha+~#sQjHUYt?EWm zdd!tez^p=lCCgl8ZIfH$r9!pHgTc}|FNsxXipVAeTw1zY8DQn58x<$y(dr`Xp$)ZLZp;;d~nO@S3g)=^v8i8KQ%|uc_7y*#ZQt6Amgd3r< zJbjqX9A;;a5{yd)FQvwSKb(!Z(L^xhQXcT9lD=NlWvJ}+11b%UgQ>#|dzha6=H83+ zf*bUwLC^#KU|;Y86`eXri>}`X%8~w}Hoo|1e6cZD%p~9OK@!|6)bwmGd6by$MUUcB Wy~qH-4nuB&O3n6S-p`zebp8YXSV(gK literal 0 HcmV?d00001 diff --git a/src/core/settings/__pycache__/__init__.cpython-313.pyc b/src/core/settings/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b09db59b9b8c3b151bde0d93bc8860de7a675c7 GIT binary patch literal 3891 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 literal 0 HcmV?d00001 diff --git a/src/relay/__pycache__/apps.cpython-313.pyc b/src/relay/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1e7e45432464adc256d22dab410d4f346d33f14 GIT binary patch literal 1384 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 WM-<`A=fA5@&()^qUJ_{V{e1^`;vvWY literal 0 HcmV?d00001 diff --git a/src/relay/__pycache__/urls.cpython-313.pyc b/src/relay/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..049a1f24aaf25ba00201a6b38190ad18d892d9b3 GIT binary patch literal 546 zcmYLGPiqrF6o0e3nQXc&!63a^+dvOnFq2CVL4-mtZcwBP_0T|BlWCH=*$r=YE%nr% zRJ_DP@iX`h{1`2{$UxEJ$y+5~pl_0fzQdc}@6GT1nKv`vUjec|!E~_30R9@XG=2wW z69VTDLB!e+GGw8J*1F+V+YTLc=GbnRLXLbLI!+y0cd1zJly@I23*MiR)8SDm;?Hc- zvpPiOj#UTJ*6uY+)S|hJwXDS{7_Ri)pz_bqz|zh*&Ih_YOuI?ZlL6EIjy#NuB;U=7 zTp|T(FJEoH+3t3;BF*=P@;H*YcH;$CAN}Dd9zR)9lDqF>CDV8$HM|Zy?e&i0w4XH! zOcdJG6CHAT6{X4#4v}=n8NP4eyqm(3x%5yb@k!$YHB)HzsQKbvyD0lZMRRG#6x1Gh zdH@Ad^#f)k1-&H(*+`1sQC~EhPlcK9U|vut?1}|4$&K-ecAt;3UXjQb_>fY~s?-j- z8=Eoq8#b??c?Db7mDR70Px&w4pQ>loiFffx{1m6vO9=mSC{;e!zPz36ec%7KKlL}J PPW{HVs@9o@<}+>ql|hXF literal 0 HcmV?d00001 diff --git a/src/relay/__pycache__/views.cpython-313.pyc b/src/relay/__pycache__/views.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..098ecc5538256568a0343fcb8060abdacdc6cba6 GIT binary patch literal 6045 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 WCL3o1E-{UwIE=!#X$2jF%zps{;r2)X literal 0 HcmV?d00001 diff --git a/src/relay/admin.py b/src/relay/admin.py new file mode 100644 index 0000000..e42a1bd --- /dev/null +++ b/src/relay/admin.py @@ -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',) + }) + ) diff --git a/src/relay/apps.py b/src/relay/apps.py new file mode 100644 index 0000000..a9cd862 --- /dev/null +++ b/src/relay/apps.py @@ -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() diff --git a/src/relay/management/__init__.py b/src/relay/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/relay/management/__pycache__/__init__.cpython-313.pyc b/src/relay/management/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b744f6bf3f46171c4713bcd7e105ffd673a9868 GIT binary patch literal 154 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 XS7`H>$h~YGA&B7HuYZqltLyte=iLF( literal 0 HcmV?d00001 diff --git a/src/relay/management/commands/list_folders.py b/src/relay/management/commands/list_folders.py new file mode 100644 index 0000000..59905d9 --- /dev/null +++ b/src/relay/management/commands/list_folders.py @@ -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}") diff --git a/src/relay/management/commands/start_imap_connections.py b/src/relay/management/commands/start_imap_connections.py new file mode 100644 index 0000000..07553d5 --- /dev/null +++ b/src/relay/management/commands/start_imap_connections.py @@ -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() diff --git a/src/relay/migrations/0001_initial.py b/src/relay/migrations/0001_initial.py new file mode 100644 index 0000000..6f1ef87 --- /dev/null +++ b/src/relay/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/src/relay/migrations/__init__.py b/src/relay/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/relay/migrations/__pycache__/0001_initial.cpython-313.pyc b/src/relay/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a1d0a13c8613918d22e7529d9651551e55db61b GIT binary patch literal 1978 zcma)6J8aul6n&&VrhYc%PwYC;$Bvs;Nn^=zlhi=t#Gl%=6DNV`rXLu1pu}g1F-0nm zloMqNP@r>`0v>}dMYoI^Jp~ylWsvZu6rH@uaEoqjFG(5I;G`uW@!t2&{hfPxk7Kbg zg6GwxH>Fnrgno0BlLvJ7Ii82tk4QoicN?v9lw0R1pGIS75=s0xl6>3TX0VIxd0q8+ z3iTEJnZTi=WOx=Xt3@hXs-c@KST+=*nS~xBG}2X$le`mj8&OU|l$SW_lX&XSA{y8Z zZua+@crqm8llL`9I|)0dT|bz=Z%Ap7Q}J0;n^mqM}l4jsrwL zAjVEb3`nAji2tXLK|qY3>ch!-XpVy*i$&Hp7Va+;3Px487_TZTj9**M_*qo0S_UpqB3ndZkqTAIGOgiV zQez>LP*v8{r-ViK2+bQN!Ma=~EI`V#svZ6Xjx#9V=OBQ3s){RSSB{q6(=HXr5kB zfdFSQvB;7;3>(f0qJHNjx~LX`HDkqz&%$3g-S_0sQc0$s7+upXQY2k1vZ(4XN8Xk2 z)#jZpXwfhNx*`1DnLPISf*%XrPc=Bz0~_F%Y?TCv5|o}>C!BeiIYOYM02#f8@7wdUlt`qpcIV{*e5?{?1i5BGP{DDg=< zadH3NQNTCSe{_B%6z=#&2ZP_Q)1?!x@4~(bDGz16XiO`4ciP+m^Sa^m*26A?#Dvei pVV8$3CkK?#MgNV!g9!zq<=i!z()exutK%ltR{{ZfA4qgBN literal 0 HcmV?d00001 diff --git a/src/relay/migrations/__pycache__/__init__.cpython-313.pyc b/src/relay/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f11d5e5b639c1505d1c862e1336d5dd27a06c06f GIT binary patch literal 154 zcmey&%ge<81jn>5XMpI(AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl ZPO4oIE6^m6O~oL_M`lJw#v*1Q3jjxRB<%nI literal 0 HcmV?d00001 diff --git a/src/relay/models.py b/src/relay/models.py new file mode 100644 index 0000000..dd321af --- /dev/null +++ b/src/relay/models.py @@ -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']) diff --git a/src/relay/serializers.py b/src/relay/serializers.py new file mode 100644 index 0000000..8535265 --- /dev/null +++ b/src/relay/serializers.py @@ -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() diff --git a/src/relay/urls.py b/src/relay/urls.py new file mode 100644 index 0000000..c7cb8e0 --- /dev/null +++ b/src/relay/urls.py @@ -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)), +] diff --git a/src/relay/utils/__pycache__/authentication.cpython-313.pyc b/src/relay/utils/__pycache__/authentication.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..705d5f7785365cacc3aa168fef80d66a1ffa2f74 GIT binary patch literal 3245 zcmahLO>Z05@h!Rh5-E~mB-x@QXC2#;2-~FOT6L;8Y7{`OWvP_I6@j90y*8pk*RaUbA5=5uiWvi};- zcn9Z~1J{Bq=-`3n5DVQ16_TFFgZUkDBQndmYu0z1yhFrbnuwuUUz-+nXknm5Zg7R; z2Rl7%*5BsruLLrEFL9CK?6_PtH>hSRCD~MT4Jdu|L5XhR!hluSpr)y46~y}&G+leR zsaK7nL0P`!NyfQt8reS$iznm;VVp>qPvn?i^f6xarwI#)JiwqBV4+Gd6S89q*O%7x zJ5)n)FC(zAc4AMszr z_b~>gj`y(O;qOFh3J^$Hh)w~p-ht`MlQdepUq}78P4$< zrUcLoR3S53JSItZtFqeul8~fzg&C%*XjIeT zGniG@OOnLU0yYf59zvVoI+P^YG?{X%YEnazz9Wu$#~BtzW(1hE2DOg=!CqDP|y&VEMVh8^*`tFQ^Q!>4kJNHT}`li|p zWjKN(WCsqcsF0$sfSJ%U#GuF1k()at7muuGh@sbb*(t?^R1Z5Wp$LaPJ(L4WhW?*N z42lu2NNfHhJnJj^dn5DaICi`{d!%NbUWOXYn`7 za(wSP-6QQx9N2k8WRo5zcz?`0rJ~=X7X24}P}v6ZcIxWd+I4B6xOR25u=M+dwWZa3 z?HJxcVS~zL$^=GB6pDO#PMEa0$=bl;gDq%JwCpw}pw`Cox_~=$rqrCS>TO-ut6I4h z`jE;D1l8VN((cKsQg)iz0W&nZP1!_2V{c-xNiDVbz+}*Kx|N@Tx*@HtE-&VVcIy@t zL(p_nDCyd|QmHapuBAoF?tuv&$+eO)rnB0p(~^Zv#Q-Zx8(E=n;BYNIc~0;gMhgc# z3qh+JfZE96f+ZN$Qi&SIdR5KDZ2q^4YYVo&LQOlU>Xiy*Hm_*lF+QH`NXK@&4=OM- zjr)qZVFw(;7?vn#9*4@|6)7)|x|KMyn>h2W(n?J4CZ>^Lf5C{Ya}l`3UpLY)rab#`dw>qrWwBQB5|yhI0-*b@W^~K z_UkTr`9^Bhimg76CjWG`H8`<5IMIksc+8`}{Gs|oW#{JKuC`Wg?ylUlmL*I2qctKo zW4F3wEH+YCtk@MVB!jtTc=|+BB0cIYNlw0v9EDtZ>L zf}4fu0}6J3=isyOoS>?~g0RmHVdlb{*p5IRuQ*xk+KkoMNkKdHp={8@3FH{rW4#@| z?7max*{@(PzLty+0Q}p}aojVKc}C7WBf>Ls@gF4qJd$ih(mzJh+wV3ar#}t;#7DT< a{}2HG8y@4%ZQpE-&+m@Uzap@3eE%6bi^3uR literal 0 HcmV?d00001 diff --git a/src/relay/utils/__pycache__/encryption.cpython-313.pyc b/src/relay/utils/__pycache__/encryption.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86aea3c183f8520b436c16de57744bc794f4ceb0 GIT binary patch literal 1595 zcmbtU&2Jl35PxsK>^O-RVro?(kt~-~xCpzg8c>yxOK>F`$sjL5YGdhX>wR%HY<9!! z`e1q~5<=yG;1a2b13?@RH~y4FJ|udiy>RL+V5DA|x4X8JsBmGVotZcDX5O3M%)Y_& zbdDhPD*dhR6hi)xiy>*LGPr}v9T^fFmY76gnAtr*Ycs!^fBxWmEc5o+0xN0IAy!gjhd7NgTwaJffX1gJ$ou_&mP zGD|?S)Rt~(E#sDE>9b^8gN$Xg47BKI$;6hdm4%7UL?tI)TLtcLKXPra?zf@08@Yl1 zT*uUHdgfGlsEXg%=&ef04MA2!%p{az`BtK+(8403b^f2KU>6!-cV#G(Ztr$h}RWe#(2HEVEP+@6@(}9bR*`JGJZA->tbj z_AUeN*}Yl_?OLLBBi9RSm@GxJy4w?3#$4Zx7!&g^1H3wP8+c4uMfQlCVv3|V&{rw+W{o=L9+BcH}j37dD?8p`d@Td!Z`)p%I z?Q?M79y!wVdte4PR8*KkAu=f(fa8Epm_`8w zWGS#20j8foxZ)paXNF1?F3HO zlll~6pLK07^_Yxpx)4U5>vKO~3|^79Qo{&@{-77}Fzz12cfG_|7}=5AW_A>T`=E<% z#(pL7xk*iO&_OaePT||gz9N4qn*P@5l&ar+vU*BT?AQO6;xwn~%lllKmVZ-UNRecc z@s*5Ff>0DsPy@ec|Mm}h)9kzGzOSb=Nk okPlW^Ms})FN`EI4Ps!D%r1U~F>GGdMvNFk+`_osx$i+GS0m-jdI{*Lx literal 0 HcmV?d00001 diff --git a/src/relay/utils/__pycache__/imap_manager.cpython-313.pyc b/src/relay/utils/__pycache__/imap_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14c3537bc186328f382ccdc5847c0e14bcffae63 GIT binary patch literal 43062 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 N1=iJzTn6iy{~vsq=js3e literal 0 HcmV?d00001 diff --git a/src/relay/utils/__pycache__/permissions.cpython-313.pyc b/src/relay/utils/__pycache__/permissions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23158416d25d7dde75b758e572c35bf5deb6ba4b GIT binary patch literal 1035 zcmZuwPfyf96rX9ke<)obqF_kW#u#HgY(Yb~sEL{w1wsfFFKjXyy8~P4wlLGWK{0`(dT#T=O2H`}D96FF ze~s~GAVd&H0$l6y{EL_IDvK5c<9@doAz+oQ_H|I(sD?Q95Z64+bx-4l>dXtC!HWuA z4n3#h6%B*>Dc*qrW$&iVr>RKdrrqsoC$Swz;f77aFmBn@_n8nX2$i6KicQm`!J;Ja zX~ODu3d}^+^gGANNGWjTx4(keMvHhM3-^kL_)aH4rx3Ey(iy_V`U8iIw(vc%bX%0wcKw*hMj zMcKA(IpsFuCUfg=R^0LNDK}`+HNsg)H(bGeH@DIx2!-37w5YCa$bkkGq#Khr{=cYd z?}k2TC?-LCK!-(Cx%_$d)9eY-DtCWbBU`O+n_o7+*LJOYCpv_FTf-{7XE{5Tb67I^ z%r+FFA85`g$uc1kZ8AcnNk}uUr=hYfLe^6nb~1y6EC*a9VGyw>hIEOLdh8RzhX6-j zAq0j7?|{%G;lb-PVFGf)kmHw>7*s+{KND)Q!UFLT9cfyjd|WOT%zUHTGY8LffWv6X zG6!Oi{!U8@PrCBfA(+kj=d~o`9XfhNBkOKs?Q^oaj0XA b_tEWrbpNl>kIk+3d!rLOqZ7vnoQ&H)V6OQW literal 0 HcmV?d00001 diff --git a/src/relay/utils/authentication.py b/src/relay/utils/authentication.py new file mode 100644 index 0000000..7b42f5b --- /dev/null +++ b/src/relay/utils/authentication.py @@ -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) diff --git a/src/relay/utils/encryption.py b/src/relay/utils/encryption.py new file mode 100644 index 0000000..f5dfc7a --- /dev/null +++ b/src/relay/utils/encryption.py @@ -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() diff --git a/src/relay/utils/imap_manager.py b/src/relay/utils/imap_manager.py new file mode 100644 index 0000000..48a81b3 --- /dev/null +++ b/src/relay/utils/imap_manager.py @@ -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() diff --git a/src/relay/utils/permissions.py b/src/relay/utils/permissions.py new file mode 100644 index 0000000..48d7b98 --- /dev/null +++ b/src/relay/utils/permissions.py @@ -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 + ) diff --git a/src/relay/views.py b/src/relay/views.py new file mode 100644 index 0000000..51fc725 --- /dev/null +++ b/src/relay/views.py @@ -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() + }) diff --git a/start_dev.sh b/start_dev.sh new file mode 100644 index 0000000..d6d8074 --- /dev/null +++ b/start_dev.sh @@ -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