This commit is contained in:
Iliyan Angelov
2025-09-14 23:24:25 +03:00
commit c67067a2a4
71311 changed files with 6800714 additions and 0 deletions

44
Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DEBIAN_FRONTEND=noninteractive
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
postgresql-client \
build-essential \
libpq-dev \
gettext \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . /app/
# Create directories for logs and media
RUN mkdir -p /app/logs /app/media /app/staticfiles
# Collect static files
RUN python manage.py collectstatic --noinput
# Create a non-root user
RUN adduser --disabled-password --gecos '' appuser
RUN chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000
# Run the application
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "gnxmail.wsgi:application"]

325
README.md Normal file
View File

@@ -0,0 +1,325 @@
# GNX Mail - Modern Email Client
A comprehensive, secure, and feature-rich email server built with Django and React. GNX Mail provides a complete email solution with modern UI, advanced security features, and enterprise-grade functionality.
## 🚀 Features
### Core Email Functionality
- **Send & Receive Emails**: Full SMTP/IMAP support with multiple email providers
- **Rich Text Editor**: Compose emails with rich formatting, attachments, and signatures
- **Email Threading**: Conversation view for better email organization
- **Advanced Search**: Powerful search with filters and saved searches
- **Email Templates**: Create and manage reusable email templates
- **Auto-reply**: Set up automatic responses for out-of-office scenarios
### Security & Privacy
- **End-to-End Encryption**: Secure email communication
- **Two-Factor Authentication**: Enhanced account security
- **Rate Limiting**: Protection against spam and abuse
- **CSRF Protection**: Cross-site request forgery protection
- **Secure Password Storage**: Encrypted password storage with bcrypt
- **Login Attempt Monitoring**: Track and prevent unauthorized access
### User Experience
- **Modern React UI**: Beautiful, responsive interface
- **Dark/Light Theme**: Customizable appearance
- **Mobile Responsive**: Works perfectly on all devices
- **Real-time Notifications**: Instant email notifications
- **Drag & Drop**: Easy file attachment handling
- **Keyboard Shortcuts**: Power user features
### Organization & Management
- **Custom Folders**: Organize emails with custom folders
- **Contact Management**: Comprehensive contact system with groups
- **Email Rules**: Automated email processing and organization
- **Bulk Actions**: Manage multiple emails at once
- **Email Signatures**: Professional email signatures
- **Import/Export**: Backup and restore functionality
### Enterprise Features
- **Multi-user Support**: Team collaboration features
- **Admin Dashboard**: Comprehensive administration panel
- **API Access**: RESTful API for integrations
- **Audit Logs**: Complete activity tracking
- **Scalable Architecture**: Built for growth
## 🛠 Technology Stack
### Backend
- **Django 4.2**: Python web framework
- **PostgreSQL**: Robust database system
- **Redis**: Caching and message broker
- **Celery**: Asynchronous task processing
- **JWT Authentication**: Secure token-based auth
- **Django REST Framework**: API development
### Frontend
- **React 18**: Modern JavaScript framework
- **Tailwind CSS**: Utility-first CSS framework
- **React Query**: Data fetching and caching
- **React Hook Form**: Form handling
- **Lucide React**: Beautiful icons
- **Framer Motion**: Smooth animations
### Infrastructure
- **Docker**: Containerization
- **Nginx**: Reverse proxy and load balancer
- **Gunicorn**: WSGI HTTP server
- **SSL/TLS**: Secure connections
## 📦 Installation
### Prerequisites
- Python 3.11+
- Node.js 16+
- PostgreSQL 13+
- Redis 6+
- Docker (optional)
### Quick Start with Docker
1. **Clone the repository**
```bash
git clone <repository-url>
cd gnx-mail
```
2. **Start with Docker Compose**
```bash
docker-compose up --build
```
3. **Access the application**
- Frontend: http://localhost:3000
- Backend API: http://localhost:8000
- Admin Panel: http://localhost:8000/admin
### Manual Installation
1. **Clone and setup**
```bash
git clone <repository-url>
cd gnx-mail
chmod +x scripts/setup.sh
./scripts/setup.sh
```
2. **Configure environment**
```bash
cp env.example .env
# Edit .env with your settings
```
3. **Start services**
```bash
# Terminal 1: Django server
source venv/bin/activate
python manage.py runserver
# Terminal 2: Celery worker
celery -A gnxmail worker -l info
# Terminal 3: Frontend
cd frontend
npm start
```
## ⚙️ Configuration
### Environment Variables
Create a `.env` file with the following variables:
```env
# Django Settings
SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DB_NAME=gnxmail
DB_USER=postgres
DB_PASSWORD=password
DB_HOST=localhost
DB_PORT=5432
# Email Settings
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
# Redis
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
```
### Email Provider Setup
#### Gmail
1. Enable 2-factor authentication
2. Generate an app password
3. Use the app password in EMAIL_HOST_PASSWORD
#### Other Providers
- **Outlook**: smtp-mail.outlook.com:587
- **Yahoo**: smtp.mail.yahoo.com:587
- **Custom SMTP**: Configure your own SMTP server
## 🔧 API Documentation
### Authentication
```bash
# Register
POST /api/accounts/register/
{
"email": "user@example.com",
"username": "username",
"first_name": "John",
"last_name": "Doe",
"password": "securepassword",
"password_confirm": "securepassword"
}
# Login
POST /api/accounts/login/
{
"email": "user@example.com",
"password": "securepassword"
}
```
### Email Operations
```bash
# Send Email
POST /api/emails/send/
{
"to_emails": ["recipient@example.com"],
"subject": "Hello",
"body_text": "Hello world!",
"attachments": [file1, file2]
}
# Get Emails
GET /api/emails/?search=keyword&is_read=false
# Mark as Read
PATCH /api/emails/{id}/
{
"is_read": true
}
```
## 🚀 Deployment
### Production Deployment
1. **Update settings for production**
```python
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com']
SECURE_SSL_REDIRECT = True
```
2. **Use Docker Compose**
```bash
docker-compose -f docker-compose.prod.yml up -d
```
3. **Set up SSL certificates**
```bash
# Using Let's Encrypt
certbot --nginx -d yourdomain.com
```
### Environment-specific Configurations
- **Development**: Use SQLite and local Redis
- **Staging**: Use PostgreSQL with Docker
- **Production**: Use managed databases and Redis
## 🔒 Security Features
### Authentication & Authorization
- JWT token-based authentication
- Password strength validation
- Account lockout after failed attempts
- Session management
### Data Protection
- Encrypted password storage
- CSRF protection
- XSS prevention
- SQL injection protection
- Rate limiting
### Email Security
- SPF, DKIM, DMARC support
- Email encryption
- Attachment scanning
- Spam filtering
## 📊 Monitoring & Logging
### Application Monitoring
- Health check endpoints
- Performance metrics
- Error tracking
- User activity logs
### Email Monitoring
- Delivery status tracking
- Bounce handling
- Spam detection
- Usage analytics
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
### Development Guidelines
- Follow PEP 8 for Python code
- Use ESLint for JavaScript
- Write comprehensive tests
- Update documentation
## 📝 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🆘 Support
### Documentation
- [API Documentation](docs/api.md)
- [User Guide](docs/user-guide.md)
- [Developer Guide](docs/developer-guide.md)
### Community
- [GitHub Issues](https://github.com/your-repo/issues)
- [Discord Community](https://discord.gg/your-invite)
- [Email Support](mailto:support@gnxmail.com)
## 🎯 Roadmap
### Upcoming Features
- [ ] Mobile apps (iOS/Android)
- [ ] Calendar integration
- [ ] Video conferencing
- [ ] Advanced AI features
- [ ] Multi-language support
- [ ] Plugin system
### Performance Improvements
- [ ] Database optimization
- [ ] Caching strategies
- [ ] CDN integration
- [ ] Load balancing
---
**GNX Mail** - The future of email communication. Built with ❤️ using Django and React.

0
accounts/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

64
accounts/admin.py Normal file
View File

@@ -0,0 +1,64 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import User, UserProfile, LoginAttempt, EmailVerification
@admin.register(User)
class UserAdmin(BaseUserAdmin):
"""Custom User admin."""
list_display = ('email', 'username', 'first_name', 'last_name', 'is_verified', 'is_active', 'date_joined')
list_filter = ('is_verified', 'is_active', 'is_staff', 'is_superuser', 'date_joined')
search_fields = ('email', 'username', 'first_name', 'last_name')
ordering = ('-date_joined',)
fieldsets = (
(None, {'fields': ('email', 'username', 'password')}),
('Personal info', {'fields': ('first_name', 'last_name', 'avatar')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('Email Settings', {'fields': (
'smtp_host', 'smtp_port', 'smtp_username', 'smtp_use_tls',
'imap_host', 'imap_port', 'imap_username', 'imap_use_ssl'
)}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
('Security', {'fields': ('is_verified', 'last_login_ip')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'username', 'first_name', 'last_name', 'password1', 'password2'),
}),
)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
"""User Profile admin."""
list_display = ('user', 'timezone', 'language', 'theme', 'auto_reply_enabled')
list_filter = ('timezone', 'language', 'theme', 'auto_reply_enabled')
search_fields = ('user__email', 'user__username')
raw_id_fields = ('user',)
@admin.register(LoginAttempt)
class LoginAttemptAdmin(admin.ModelAdmin):
"""Login Attempt admin."""
list_display = ('email', 'ip_address', 'success', 'failure_reason', 'timestamp')
list_filter = ('success', 'timestamp')
search_fields = ('email', 'ip_address')
readonly_fields = ('timestamp',)
ordering = ('-timestamp',)
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin):
"""Email Verification admin."""
list_display = ('user', 'token', 'is_used', 'created_at', 'expires_at')
list_filter = ('is_used', 'created_at', 'expires_at')
search_fields = ('user__email', 'token')
readonly_fields = ('token', 'created_at')
raw_id_fields = ('user',)

6
accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

View File

@@ -0,0 +1,112 @@
# Generated by Django 4.2.7 on 2025-09-14 20:10
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)),
('first_name', models.CharField(max_length=30)),
('last_name', models.CharField(max_length=30)),
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
('is_verified', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_login_ip', models.GenericIPAddressField(blank=True, null=True)),
('smtp_host', models.CharField(blank=True, max_length=255)),
('smtp_port', models.IntegerField(default=587)),
('smtp_username', models.CharField(blank=True, max_length=255)),
('smtp_password', models.BinaryField(blank=True)),
('smtp_use_tls', models.BooleanField(default=True)),
('imap_host', models.CharField(blank=True, max_length=255)),
('imap_port', models.IntegerField(default=993)),
('imap_username', models.CharField(blank=True, max_length=255)),
('imap_password', models.BinaryField(blank=True)),
('imap_use_ssl', models.BooleanField(default=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'db_table': 'users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('signature', models.TextField(blank=True)),
('auto_reply_enabled', models.BooleanField(default=False)),
('auto_reply_message', models.TextField(blank=True)),
('timezone', models.CharField(default='UTC', max_length=50)),
('language', models.CharField(default='en', max_length=10)),
('theme', models.CharField(default='light', max_length=20)),
('emails_per_page', models.IntegerField(default=20)),
('auto_save_drafts', models.BooleanField(default=True)),
('show_images', models.BooleanField(default=True)),
('mark_as_read_delay', models.IntegerField(default=3)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'user_profiles',
},
),
migrations.CreateModel(
name='LoginAttempt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('ip_address', models.GenericIPAddressField()),
('user_agent', models.TextField()),
('success', models.BooleanField(default=False)),
('failure_reason', models.CharField(blank=True, max_length=100)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'login_attempts',
'ordering': ['-timestamp'],
},
),
migrations.CreateModel(
name='EmailVerification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(max_length=100, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('is_used', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_verifications',
},
),
]

View File

142
accounts/models.py Normal file
View File

@@ -0,0 +1,142 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
from cryptography.fernet import Fernet
import os
class User(AbstractUser):
"""Custom User model with additional fields for email functionality."""
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30)
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
is_verified = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_login_ip = models.GenericIPAddressField(null=True, blank=True)
# Email server settings
smtp_host = models.CharField(max_length=255, blank=True)
smtp_port = models.IntegerField(default=587)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.BinaryField(blank=True) # Encrypted
smtp_use_tls = models.BooleanField(default=True)
imap_host = models.CharField(max_length=255, blank=True)
imap_port = models.IntegerField(default=993)
imap_username = models.CharField(max_length=255, blank=True)
imap_password = models.BinaryField(blank=True) # Encrypted
imap_use_ssl = models.BooleanField(default=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username', 'first_name', 'last_name']
class Meta:
db_table = 'users'
def __str__(self):
return self.email
def get_full_name(self):
return f"{self.first_name} {self.last_name}".strip()
def get_short_name(self):
return self.first_name
def encrypt_password(self, password):
"""Encrypt password for storage."""
key = os.environ.get('ENCRYPTION_KEY', Fernet.generate_key())
f = Fernet(key)
return f.encrypt(password.encode())
def decrypt_password(self, encrypted_password):
"""Decrypt password for use."""
if not encrypted_password:
return None
key = os.environ.get('ENCRYPTION_KEY', Fernet.generate_key())
f = Fernet(key)
return f.decrypt(encrypted_password).decode()
def set_smtp_password(self, password):
"""Set encrypted SMTP password."""
self.smtp_password = self.encrypt_password(password)
def get_smtp_password(self):
"""Get decrypted SMTP password."""
return self.decrypt_password(self.smtp_password)
def set_imap_password(self, password):
"""Set encrypted IMAP password."""
self.imap_password = self.encrypt_password(password)
def get_imap_password(self):
"""Get decrypted IMAP password."""
return self.decrypt_password(self.imap_password)
class UserProfile(models.Model):
"""Extended user profile information."""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
signature = models.TextField(blank=True)
auto_reply_enabled = models.BooleanField(default=False)
auto_reply_message = models.TextField(blank=True)
timezone = models.CharField(max_length=50, default='UTC')
language = models.CharField(max_length=10, default='en')
theme = models.CharField(max_length=20, default='light')
# Email preferences
emails_per_page = models.IntegerField(default=20)
auto_save_drafts = models.BooleanField(default=True)
show_images = models.BooleanField(default=True)
mark_as_read_delay = models.IntegerField(default=3) # seconds
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'user_profiles'
def __str__(self):
return f"{self.user.email} Profile"
class LoginAttempt(models.Model):
"""Track login attempts for security."""
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
email = models.EmailField()
ip_address = models.GenericIPAddressField()
user_agent = models.TextField()
success = models.BooleanField(default=False)
failure_reason = models.CharField(max_length=100, blank=True)
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'login_attempts'
ordering = ['-timestamp']
def __str__(self):
status = "Success" if self.success else "Failed"
return f"{self.email} - {status} - {self.timestamp}"
class EmailVerification(models.Model):
"""Email verification tokens."""
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
class Meta:
db_table = 'email_verifications'
def __str__(self):
return f"{self.user.email} - {self.token}"
def is_expired(self):
return timezone.now() > self.expires_at

204
accounts/serializers.py Normal file
View File

@@ -0,0 +1,204 @@
from rest_framework import serializers
from django.contrib.auth import authenticate
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from .models import User, UserProfile, LoginAttempt, EmailVerification
import uuid
from datetime import timedelta
from django.utils import timezone
class UserRegistrationSerializer(serializers.ModelSerializer):
"""Serializer for user registration."""
password = serializers.CharField(write_only=True, validators=[validate_password])
password_confirm = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ('email', 'username', 'first_name', 'last_name', 'password', 'password_confirm')
extra_kwargs = {
'email': {'required': True},
'username': {'required': True},
'first_name': {'required': True},
'last_name': {'required': True},
}
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError("Passwords don't match.")
return attrs
def validate_email(self, value):
if User.objects.filter(email=value).exists():
raise serializers.ValidationError("A user with this email already exists.")
return value
def validate_username(self, value):
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("A user with this username already exists.")
return value
def create(self, validated_data):
validated_data.pop('password_confirm')
user = User.objects.create_user(**validated_data)
# Create user profile
UserProfile.objects.create(user=user)
# Create email verification token
token = str(uuid.uuid4())
EmailVerification.objects.create(
user=user,
token=token,
expires_at=timezone.now() + timedelta(hours=24)
)
return user
class UserLoginSerializer(serializers.Serializer):
"""Serializer for user login."""
email = serializers.EmailField()
password = serializers.CharField()
def validate(self, attrs):
email = attrs.get('email')
password = attrs.get('password')
if email and password:
user = authenticate(username=email, password=password)
if not user:
raise serializers.ValidationError('Invalid email or password.')
if not user.is_active:
raise serializers.ValidationError('User account is disabled.')
attrs['user'] = user
else:
raise serializers.ValidationError('Must include email and password.')
return attrs
class UserSerializer(serializers.ModelSerializer):
"""Serializer for user data."""
full_name = serializers.CharField(source='get_full_name', read_only=True)
profile = serializers.SerializerMethodField()
class Meta:
model = User
fields = (
'id', 'email', 'username', 'first_name', 'last_name', 'full_name',
'avatar', 'is_verified', 'created_at', 'updated_at', 'profile'
)
read_only_fields = ('id', 'created_at', 'updated_at', 'is_verified')
def get_profile(self, obj):
try:
profile = obj.profile
return UserProfileSerializer(profile).data
except UserProfile.DoesNotExist:
return None
class UserProfileSerializer(serializers.ModelSerializer):
"""Serializer for user profile."""
class Meta:
model = UserProfile
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
class EmailSettingsSerializer(serializers.ModelSerializer):
"""Serializer for email server settings."""
smtp_password = serializers.CharField(write_only=True, required=False)
imap_password = serializers.CharField(write_only=True, required=False)
class Meta:
model = User
fields = (
'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'smtp_use_tls', 'imap_host', 'imap_port', 'imap_username',
'imap_password', 'imap_use_ssl'
)
def update(self, instance, validated_data):
smtp_password = validated_data.pop('smtp_password', None)
imap_password = validated_data.pop('imap_password', None)
if smtp_password:
instance.set_smtp_password(smtp_password)
if imap_password:
instance.set_imap_password(imap_password)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
class PasswordChangeSerializer(serializers.Serializer):
"""Serializer for password change."""
old_password = serializers.CharField()
new_password = serializers.CharField(validators=[validate_password])
new_password_confirm = serializers.CharField()
def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError('Old password is incorrect.')
return value
def validate(self, attrs):
if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError("New passwords don't match.")
return attrs
def save(self):
user = self.context['request'].user
user.set_password(self.validated_data['new_password'])
user.save()
return user
class EmailVerificationSerializer(serializers.Serializer):
"""Serializer for email verification."""
token = serializers.CharField()
def validate_token(self, value):
try:
verification = EmailVerification.objects.get(token=value)
if verification.is_used:
raise serializers.ValidationError('Verification token has already been used.')
if verification.is_expired():
raise serializers.ValidationError('Verification token has expired.')
return verification
except EmailVerification.DoesNotExist:
raise serializers.ValidationError('Invalid verification token.')
def save(self):
verification = self.validated_data['token']
verification.is_used = True
verification.save()
user = verification.user
user.is_verified = True
user.save()
return user
class LoginAttemptSerializer(serializers.ModelSerializer):
"""Serializer for login attempts."""
class Meta:
model = LoginAttempt
fields = '__all__'
read_only_fields = ('timestamp',)

16
accounts/urls.py Normal file
View File

@@ -0,0 +1,16 @@
from django.urls import path
from . import views
urlpatterns = [
path('register/', views.UserRegistrationView.as_view(), name='user-register'),
path('login/', views.UserLoginView.as_view(), name='user-login'),
path('logout/', views.LogoutView.as_view(), name='user-logout'),
path('profile/', views.UserProfileView.as_view(), name='user-profile'),
path('profile/settings/', views.UserProfileSettingsView.as_view(), name='user-profile-settings'),
path('email-settings/', views.EmailSettingsView.as_view(), name='email-settings'),
path('change-password/', views.PasswordChangeView.as_view(), name='change-password'),
path('verify-email/', views.EmailVerificationView.as_view(), name='verify-email'),
path('resend-verification/', views.ResendVerificationView.as_view(), name='resend-verification'),
path('login-attempts/', views.LoginAttemptsView.as_view(), name='login-attempts'),
path('stats/', views.user_stats, name='user-stats'),
]

230
accounts/views.py Normal file
View File

@@ -0,0 +1,230 @@
from rest_framework import status, generics, permissions
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import login
from django.utils import timezone
from django_ratelimit.decorators import ratelimit
from django.db import transaction
import uuid
from .models import User, UserProfile, LoginAttempt, EmailVerification
from .serializers import (
UserRegistrationSerializer, UserLoginSerializer, UserSerializer,
UserProfileSerializer, EmailSettingsSerializer, PasswordChangeSerializer,
EmailVerificationSerializer, LoginAttemptSerializer
)
class UserRegistrationView(APIView):
"""User registration endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = UserRegistrationSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
# Generate tokens
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
return Response({
'message': 'User registered successfully. Please verify your email.',
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(access_token),
}
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class UserLoginView(APIView):
"""User login endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = UserLoginSerializer(data=request.data)
if serializer.is_valid():
user = serializer.validated_data['user']
# Log login attempt
LoginAttempt.objects.create(
user=user,
email=user.email,
ip_address=self.get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
success=True
)
# Generate tokens
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
# Update last login IP
user.last_login_ip = self.get_client_ip(request)
user.save()
return Response({
'message': 'Login successful',
'user': UserSerializer(user).data,
'tokens': {
'refresh': str(refresh),
'access': str(access_token),
}
}, status=status.HTTP_200_OK)
# Log failed login attempt
email = request.data.get('email', '')
LoginAttempt.objects.create(
email=email,
ip_address=self.get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
success=False,
failure_reason='Invalid credentials'
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_client_ip(self, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
class UserProfileView(generics.RetrieveUpdateAPIView):
"""User profile management."""
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
return self.request.user
class UserProfileSettingsView(generics.RetrieveUpdateAPIView):
"""User profile settings management."""
serializer_class = UserProfileSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
profile, created = UserProfile.objects.get_or_create(user=self.request.user)
return profile
class EmailSettingsView(generics.RetrieveUpdateAPIView):
"""Email server settings management."""
serializer_class = EmailSettingsSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
return self.request.user
class PasswordChangeView(APIView):
"""Password change endpoint."""
permission_classes = [permissions.IsAuthenticated]
@ratelimit(key='user', rate='5/m', method=['POST'])
def post(self, request):
serializer = PasswordChangeSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
serializer.save()
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class EmailVerificationView(APIView):
"""Email verification endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = EmailVerificationSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
return Response({
'message': 'Email verified successfully',
'user': UserSerializer(user).data
}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ResendVerificationView(APIView):
"""Resend email verification."""
permission_classes = [permissions.IsAuthenticated]
@ratelimit(key='user', rate='3/m', method=['POST'])
def post(self, request):
user = request.user
if user.is_verified:
return Response({'message': 'Email is already verified'}, status=status.HTTP_400_BAD_REQUEST)
# Create new verification token
token = str(uuid.uuid4())
EmailVerification.objects.create(
user=user,
token=token,
expires_at=timezone.now() + timezone.timedelta(hours=24)
)
# TODO: Send verification email
return Response({'message': 'Verification email sent'}, status=status.HTTP_200_OK)
class LoginAttemptsView(generics.ListAPIView):
"""View login attempts for security monitoring."""
serializer_class = LoginAttemptSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return LoginAttempt.objects.filter(user=self.request.user).order_by('-timestamp')[:50]
class LogoutView(APIView):
"""Logout endpoint."""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
try:
refresh_token = request.data["refresh"]
token = RefreshToken(refresh_token)
token.blacklist()
return Response({'message': 'Logout successful'}, status=status.HTTP_200_OK)
except Exception as e:
return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def user_stats(request):
"""Get user statistics."""
user = request.user
stats = {
'total_emails': 0, # Will be implemented with emails app
'unread_emails': 0, # Will be implemented with emails app
'sent_emails': 0, # Will be implemented with emails app
'draft_emails': 0, # Will be implemented with emails app
'last_login': user.last_login,
'is_verified': user.is_verified,
'account_created': user.date_joined,
}
return Response(stats)

0
contacts/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

55
contacts/admin.py Normal file
View File

@@ -0,0 +1,55 @@
from django.contrib import admin
from .models import Contact, ContactGroup, ContactInteraction, ContactImport
@admin.register(ContactGroup)
class ContactGroupAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'created_at')
list_filter = ('created_at',)
search_fields = ('name', 'user__email')
raw_id_fields = ('user',)
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'company', 'user', 'is_favorite', 'is_blocked', 'created_at')
list_filter = ('is_favorite', 'is_blocked', 'company', 'city', 'country', 'created_at')
search_fields = ('first_name', 'last_name', 'email', 'company', 'phone')
raw_id_fields = ('user', 'group')
date_hierarchy = 'created_at'
fieldsets = (
('Basic Information', {
'fields': ('user', 'group', 'first_name', 'last_name', 'email', 'phone')
}),
('Work Information', {
'fields': ('company', 'job_title', 'website')
}),
('Address', {
'fields': ('address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country')
}),
('Social Media', {
'fields': ('linkedin', 'twitter', 'facebook')
}),
('Additional', {
'fields': ('avatar', 'notes', 'birthday', 'is_favorite', 'is_blocked')
}),
)
@admin.register(ContactInteraction)
class ContactInteractionAdmin(admin.ModelAdmin):
list_display = ('contact', 'interaction_type', 'subject', 'created_by', 'date')
list_filter = ('interaction_type', 'date')
search_fields = ('contact__first_name', 'contact__last_name', 'subject')
raw_id_fields = ('contact', 'created_by')
date_hierarchy = 'date'
@admin.register(ContactImport)
class ContactImportAdmin(admin.ModelAdmin):
list_display = ('filename', 'user', 'status', 'total_contacts', 'imported_contacts', 'failed_contacts', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('filename', 'user__email')
raw_id_fields = ('user',)
readonly_fields = ('status', 'total_contacts', 'imported_contacts', 'failed_contacts', 'error_log', 'created_at', 'completed_at')

6
contacts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'contacts'

View File

@@ -0,0 +1,129 @@
# Generated by Django 4.2.7 on 2025-09-14 20:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Contact',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=50)),
('last_name', models.CharField(max_length=50)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('company', models.CharField(blank=True, max_length=100)),
('job_title', models.CharField(blank=True, max_length=100)),
('avatar', models.ImageField(blank=True, null=True, upload_to='contact_avatars/')),
('notes', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('birthday', models.DateField(blank=True, null=True)),
('address_line1', models.CharField(blank=True, max_length=100)),
('address_line2', models.CharField(blank=True, max_length=100)),
('city', models.CharField(blank=True, max_length=50)),
('state', models.CharField(blank=True, max_length=50)),
('postal_code', models.CharField(blank=True, max_length=20)),
('country', models.CharField(blank=True, max_length=50)),
('linkedin', models.URLField(blank=True)),
('twitter', models.URLField(blank=True)),
('facebook', models.URLField(blank=True)),
('is_favorite', models.BooleanField(default=False)),
('is_blocked', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'contacts',
'ordering': ['first_name', 'last_name'],
},
),
migrations.CreateModel(
name='ContactInteraction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('interaction_type', models.CharField(choices=[('email_sent', 'Email Sent'), ('email_received', 'Email Received'), ('phone_call', 'Phone Call'), ('meeting', 'Meeting'), ('note', 'Note')], max_length=20)),
('subject', models.CharField(blank=True, max_length=200)),
('description', models.TextField(blank=True)),
('date', models.DateTimeField(auto_now_add=True)),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='contacts.contact')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'contact_interactions',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ContactImport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('filename', models.CharField(max_length=255)),
('file', models.FileField(upload_to='contact_imports/')),
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)),
('total_contacts', models.IntegerField(default=0)),
('imported_contacts', models.IntegerField(default=0)),
('failed_contacts', models.IntegerField(default=0)),
('error_log', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_imports', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'contact_imports',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ContactGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('color', models.CharField(default='#007bff', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'contact_groups',
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
migrations.AddField(
model_name='contact',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='contacts.contactgroup'),
),
migrations.AddField(
model_name='contact',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL),
),
migrations.AddIndex(
model_name='contact',
index=models.Index(fields=['user', 'email'], name='contacts_user_id_bbbaf8_idx'),
),
migrations.AddIndex(
model_name='contact',
index=models.Index(fields=['user', 'is_favorite'], name='contacts_user_id_80e197_idx'),
),
migrations.AddIndex(
model_name='contact',
index=models.Index(fields=['user', 'is_blocked'], name='contacts_user_id_0ee143_idx'),
),
migrations.AlterUniqueTogether(
name='contact',
unique_together={('user', 'email')},
),
]

View File

148
contacts/models.py Normal file
View File

@@ -0,0 +1,148 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.core.validators import EmailValidator
import uuid
User = get_user_model()
class ContactGroup(models.Model):
"""Contact groups for organizing contacts."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='contact_groups')
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
color = models.CharField(max_length=7, default='#007bff') # Hex color
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'contact_groups'
unique_together = ['user', 'name']
ordering = ['name']
def __str__(self):
return f"{self.user.email} - {self.name}"
class Contact(models.Model):
"""Contact model for storing contact information."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='contacts')
group = models.ForeignKey(ContactGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name='contacts')
# Basic information
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
company = models.CharField(max_length=100, blank=True)
job_title = models.CharField(max_length=100, blank=True)
# Additional information
avatar = models.ImageField(upload_to='contact_avatars/', null=True, blank=True)
notes = models.TextField(blank=True)
website = models.URLField(blank=True)
birthday = models.DateField(null=True, blank=True)
# Address information
address_line1 = models.CharField(max_length=100, blank=True)
address_line2 = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=50, blank=True)
state = models.CharField(max_length=50, blank=True)
postal_code = models.CharField(max_length=20, blank=True)
country = models.CharField(max_length=50, blank=True)
# Social media
linkedin = models.URLField(blank=True)
twitter = models.URLField(blank=True)
facebook = models.URLField(blank=True)
# Metadata
is_favorite = models.BooleanField(default=False)
is_blocked = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'contacts'
unique_together = ['user', 'email']
ordering = ['first_name', 'last_name']
indexes = [
models.Index(fields=['user', 'email']),
models.Index(fields=['user', 'is_favorite']),
models.Index(fields=['user', 'is_blocked']),
]
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.email})"
def get_full_name(self):
return f"{self.first_name} {self.last_name}".strip()
def get_address(self):
"""Get formatted address."""
address_parts = [
self.address_line1,
self.address_line2,
self.city,
self.state,
self.postal_code,
self.country
]
return ', '.join([part for part in address_parts if part])
class ContactInteraction(models.Model):
"""Track interactions with contacts."""
INTERACTION_TYPES = [
('email_sent', 'Email Sent'),
('email_received', 'Email Received'),
('phone_call', 'Phone Call'),
('meeting', 'Meeting'),
('note', 'Note'),
]
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name='interactions')
interaction_type = models.CharField(max_length=20, choices=INTERACTION_TYPES)
subject = models.CharField(max_length=200, blank=True)
description = models.TextField(blank=True)
date = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
db_table = 'contact_interactions'
ordering = ['-date']
def __str__(self):
return f"{self.contact.get_full_name()} - {self.get_interaction_type_display()}"
class ContactImport(models.Model):
"""Track contact imports."""
STATUS_CHOICES = [
('pending', 'Pending'),
('processing', 'Processing'),
('completed', 'Completed'),
('failed', 'Failed'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='contact_imports')
filename = models.CharField(max_length=255)
file = models.FileField(upload_to='contact_imports/')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
total_contacts = models.IntegerField(default=0)
imported_contacts = models.IntegerField(default=0)
failed_contacts = models.IntegerField(default=0)
error_log = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = 'contact_imports'
ordering = ['-created_at']
def __str__(self):
return f"{self.user.email} - {self.filename}"

126
contacts/serializers.py Normal file
View File

@@ -0,0 +1,126 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Contact, ContactGroup, ContactInteraction, ContactImport
User = get_user_model()
class ContactGroupSerializer(serializers.ModelSerializer):
"""Serializer for contact groups."""
contact_count = serializers.SerializerMethodField()
class Meta:
model = ContactGroup
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
def get_contact_count(self, obj):
return obj.contacts.count()
class ContactSerializer(serializers.ModelSerializer):
"""Serializer for contacts."""
full_name = serializers.CharField(source='get_full_name', read_only=True)
address = serializers.CharField(source='get_address', read_only=True)
group_name = serializers.CharField(source='group.name', read_only=True)
interaction_count = serializers.SerializerMethodField()
class Meta:
model = Contact
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
def get_interaction_count(self, obj):
return obj.interactions.count()
class ContactCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating contacts."""
class Meta:
model = Contact
fields = (
'group', 'first_name', 'last_name', 'email', 'phone', 'company',
'job_title', 'notes', 'website', 'birthday', 'address_line1',
'address_line2', 'city', 'state', 'postal_code', 'country',
'linkedin', 'twitter', 'facebook', 'is_favorite', 'is_blocked'
)
def validate_email(self, value):
user = self.context['request'].user
if Contact.objects.filter(user=user, email=value).exists():
raise serializers.ValidationError("A contact with this email already exists.")
return value
class ContactInteractionSerializer(serializers.ModelSerializer):
"""Serializer for contact interactions."""
contact_name = serializers.CharField(source='contact.get_full_name', read_only=True)
created_by_name = serializers.CharField(source='created_by.get_full_name', read_only=True)
class Meta:
model = ContactInteraction
fields = '__all__'
read_only_fields = ('created_by', 'date')
class ContactImportSerializer(serializers.ModelSerializer):
"""Serializer for contact imports."""
class Meta:
model = ContactImport
fields = '__all__'
read_only_fields = ('user', 'status', 'total_contacts', 'imported_contacts', 'failed_contacts', 'error_log', 'created_at', 'completed_at')
class ContactBulkActionSerializer(serializers.Serializer):
"""Serializer for bulk contact actions."""
ACTION_CHOICES = [
('add_to_group', 'Add to group'),
('remove_from_group', 'Remove from group'),
('mark_favorite', 'Mark as favorite'),
('mark_unfavorite', 'Mark as unfavorite'),
('block', 'Block'),
('unblock', 'Unblock'),
('delete', 'Delete'),
]
contact_ids = serializers.ListField(
child=serializers.IntegerField(),
min_length=1
)
action = serializers.ChoiceField(choices=ACTION_CHOICES)
group_id = serializers.IntegerField(required=False)
def validate_contact_ids(self, value):
user = self.context['request'].user
# Verify all contacts belong to the user
contact_count = Contact.objects.filter(id__in=value, user=user).count()
if contact_count != len(value):
raise serializers.ValidationError("Some contacts don't exist or don't belong to you.")
return value
def validate(self, attrs):
action = attrs.get('action')
group_id = attrs.get('group_id')
if action in ['add_to_group', 'remove_from_group'] and not group_id:
raise serializers.ValidationError("group_id is required for group actions.")
return attrs
class ContactSearchSerializer(serializers.Serializer):
"""Serializer for contact search."""
query = serializers.CharField(required=False)
group = serializers.IntegerField(required=False)
is_favorite = serializers.BooleanField(required=False)
is_blocked = serializers.BooleanField(required=False)
company = serializers.CharField(required=False)
city = serializers.CharField(required=False)
country = serializers.CharField(required=False)

137
contacts/tasks.py Normal file
View File

@@ -0,0 +1,137 @@
from celery import shared_task
import csv
import io
import logging
from django.utils import timezone
from django.core.files.base import ContentFile
from .models import Contact, ContactGroup, ContactImport
logger = logging.getLogger(__name__)
@shared_task
def process_contact_import(import_id):
"""Process contact import from CSV file."""
try:
import_obj = ContactImport.objects.get(id=import_id)
import_obj.status = 'processing'
import_obj.save()
# Read CSV file
csv_file = import_obj.file.read().decode('utf-8')
csv_reader = csv.DictReader(io.StringIO(csv_file))
total_contacts = 0
imported_contacts = 0
failed_contacts = 0
error_log = []
for row in csv_reader:
total_contacts += 1
try:
# Extract contact data
first_name = row.get('First Name', '').strip()
last_name = row.get('Last Name', '').strip()
email = row.get('Email', '').strip()
if not email:
error_log.append(f"Row {total_contacts}: Email is required")
failed_contacts += 1
continue
# Check if contact already exists
if Contact.objects.filter(user=import_obj.user, email=email).exists():
error_log.append(f"Row {total_contacts}: Contact with email {email} already exists")
failed_contacts += 1
continue
# Get or create group
group = None
group_name = row.get('Group', '').strip()
if group_name:
group, created = ContactGroup.objects.get_or_create(
user=import_obj.user,
name=group_name,
defaults={'description': f'Imported from {import_obj.filename}'}
)
# Create contact
contact = Contact.objects.create(
user=import_obj.user,
group=group,
first_name=first_name,
last_name=last_name,
email=email,
phone=row.get('Phone', '').strip(),
company=row.get('Company', '').strip(),
job_title=row.get('Job Title', '').strip(),
address_line1=row.get('Address Line 1', '').strip(),
address_line2=row.get('Address Line 2', '').strip(),
city=row.get('City', '').strip(),
state=row.get('State', '').strip(),
postal_code=row.get('Postal Code', '').strip(),
country=row.get('Country', '').strip(),
website=row.get('Website', '').strip(),
linkedin=row.get('LinkedIn', '').strip(),
twitter=row.get('Twitter', '').strip(),
facebook=row.get('Facebook', '').strip(),
notes=row.get('Notes', '').strip(),
is_favorite=row.get('Is Favorite', '').lower() == 'true',
is_blocked=row.get('Is Blocked', '').lower() == 'true',
)
imported_contacts += 1
logger.info(f"Imported contact {contact.id} for user {import_obj.user.id}")
except Exception as e:
error_log.append(f"Row {total_contacts}: {str(e)}")
failed_contacts += 1
logger.error(f"Error importing contact row {total_contacts}: {str(e)}")
# Update import status
import_obj.status = 'completed'
import_obj.total_contacts = total_contacts
import_obj.imported_contacts = imported_contacts
import_obj.failed_contacts = failed_contacts
import_obj.error_log = '\n'.join(error_log)
import_obj.completed_at = timezone.now()
import_obj.save()
logger.info(f"Contact import {import_id} completed: {imported_contacts} imported, {failed_contacts} failed")
except Exception as e:
logger.error(f"Failed to process contact import {import_id}: {str(e)}")
try:
import_obj = ContactImport.objects.get(id=import_id)
import_obj.status = 'failed'
import_obj.error_log = str(e)
import_obj.save()
except ContactImport.DoesNotExist:
pass
@shared_task
def cleanup_contact_imports():
"""Clean up old contact import files."""
try:
from django.utils import timezone
from datetime import timedelta
# Delete import files older than 7 days
cutoff_date = timezone.now() - timedelta(days=7)
old_imports = ContactImport.objects.filter(
created_at__lt=cutoff_date,
status__in=['completed', 'failed']
)
count = old_imports.count()
for import_obj in old_imports:
if import_obj.file:
import_obj.file.delete()
import_obj.delete()
logger.info(f"Cleaned up {count} old contact import files")
except Exception as e:
logger.error(f"Failed to cleanup contact imports: {str(e)}")

25
contacts/urls.py Normal file
View File

@@ -0,0 +1,25 @@
from django.urls import path
from . import views
urlpatterns = [
# Contact groups
path('groups/', views.ContactGroupListCreateView.as_view(), name='contact-group-list'),
path('groups/<int:pk>/', views.ContactGroupDetailView.as_view(), name='contact-group-detail'),
# Contacts
path('', views.ContactListCreateView.as_view(), name='contact-list'),
path('<int:pk>/', views.ContactDetailView.as_view(), name='contact-detail'),
path('search/', views.ContactSearchView.as_view(), name='contact-search'),
path('bulk-action/', views.ContactBulkActionView.as_view(), name='contact-bulk-action'),
path('suggestions/', views.contact_suggestions, name='contact-suggestions'),
path('stats/', views.contact_stats, name='contact-stats'),
path('export/', views.export_contacts, name='export-contacts'),
# Contact interactions
path('<int:contact_id>/interactions/', views.ContactInteractionListCreateView.as_view(), name='contact-interaction-list'),
path('interactions/<int:pk>/', views.ContactInteractionDetailView.as_view(), name='contact-interaction-detail'),
# Contact imports
path('import/', views.ContactImportView.as_view(), name='contact-import'),
path('imports/<int:pk>/', views.ContactImportDetailView.as_view(), name='contact-import-detail'),
]

299
contacts/views.py Normal file
View File

@@ -0,0 +1,299 @@
from rest_framework import generics, status, permissions, filters
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
from django_filters.rest_framework import DjangoFilterBackend
from django.db import models
from django.db.models import Q, Count
from django.shortcuts import get_object_or_404
from django_ratelimit.decorators import ratelimit
from django.http import HttpResponse
import csv
import io
from datetime import datetime
from .models import Contact, ContactGroup, ContactInteraction, ContactImport
from .serializers import (
ContactSerializer, ContactCreateSerializer, ContactGroupSerializer,
ContactInteractionSerializer, ContactImportSerializer, ContactBulkActionSerializer,
ContactSearchSerializer
)
from .tasks import process_contact_import
class ContactGroupListCreateView(generics.ListCreateAPIView):
"""List and create contact groups."""
serializer_class = ContactGroupSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ContactGroup.objects.filter(user=self.request.user)
class ContactGroupDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete contact group."""
serializer_class = ContactGroupSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ContactGroup.objects.filter(user=self.request.user)
class ContactListCreateView(generics.ListCreateAPIView):
"""List and create contacts."""
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['group', 'is_favorite', 'is_blocked', 'company', 'city', 'country']
search_fields = ['first_name', 'last_name', 'email', 'company', 'phone']
ordering_fields = ['first_name', 'last_name', 'email', 'created_at']
ordering = ['first_name', 'last_name']
def get_serializer_class(self):
if self.request.method == 'POST':
return ContactCreateSerializer
return ContactSerializer
def get_queryset(self):
return Contact.objects.filter(user=self.request.user).select_related('group')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ContactDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete contact."""
serializer_class = ContactSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Contact.objects.filter(user=self.request.user).select_related('group')
class ContactSearchView(generics.ListAPIView):
"""Search contacts with advanced filters."""
serializer_class = ContactSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = Contact.objects.filter(user=self.request.user)
# Get search parameters
query = self.request.query_params.get('q', '')
group = self.request.query_params.get('group')
is_favorite = self.request.query_params.get('is_favorite')
is_blocked = self.request.query_params.get('is_blocked')
company = self.request.query_params.get('company')
city = self.request.query_params.get('city')
country = self.request.query_params.get('country')
# Apply filters
if query:
queryset = queryset.filter(
Q(first_name__icontains=query) |
Q(last_name__icontains=query) |
Q(email__icontains=query) |
Q(company__icontains=query) |
Q(phone__icontains=query)
)
if group:
queryset = queryset.filter(group_id=group)
if is_favorite is not None:
queryset = queryset.filter(is_favorite=is_favorite.lower() == 'true')
if is_blocked is not None:
queryset = queryset.filter(is_blocked=is_blocked.lower() == 'true')
if company:
queryset = queryset.filter(company__icontains=company)
if city:
queryset = queryset.filter(city__icontains=city)
if country:
queryset = queryset.filter(country__icontains=country)
return queryset.select_related('group')
class ContactBulkActionView(APIView):
"""Perform bulk actions on contacts."""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
serializer = ContactBulkActionSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
contact_ids = serializer.validated_data['contact_ids']
action = serializer.validated_data['action']
group_id = serializer.validated_data.get('group_id')
contacts = Contact.objects.filter(id__in=contact_ids, user=request.user)
if action == 'add_to_group':
group = get_object_or_404(ContactGroup, id=group_id, user=request.user)
contacts.update(group=group)
elif action == 'remove_from_group':
contacts.update(group=None)
elif action == 'mark_favorite':
contacts.update(is_favorite=True)
elif action == 'mark_unfavorite':
contacts.update(is_favorite=False)
elif action == 'block':
contacts.update(is_blocked=True)
elif action == 'unblock':
contacts.update(is_blocked=False)
elif action == 'delete':
contacts.delete()
return Response({'message': f'Bulk action {action} completed successfully'})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ContactInteractionListCreateView(generics.ListCreateAPIView):
"""List and create contact interactions."""
serializer_class = ContactInteractionSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
contact_id = self.kwargs.get('contact_id')
return ContactInteraction.objects.filter(
contact_id=contact_id,
contact__user=self.request.user
).order_by('-date')
def perform_create(self, serializer):
contact_id = self.kwargs.get('contact_id')
contact = get_object_or_404(Contact, id=contact_id, user=self.request.user)
serializer.save(contact=contact, created_by=self.request.user)
class ContactInteractionDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete contact interaction."""
serializer_class = ContactInteractionSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ContactInteraction.objects.filter(contact__user=self.request.user)
class ContactImportView(generics.CreateAPIView):
"""Import contacts from CSV file."""
serializer_class = ContactImportSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
import_obj = serializer.save(user=self.request.user)
# Process import asynchronously
process_contact_import.delay(import_obj.id)
class ContactImportDetailView(generics.RetrieveAPIView):
"""Retrieve contact import status."""
serializer_class = ContactImportSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ContactImport.objects.filter(user=self.request.user)
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def contact_stats(request):
"""Get contact statistics."""
user = request.user
stats = {
'total_contacts': Contact.objects.filter(user=user).count(),
'favorite_contacts': Contact.objects.filter(user=user, is_favorite=True).count(),
'blocked_contacts': Contact.objects.filter(user=user, is_blocked=True).count(),
'total_groups': ContactGroup.objects.filter(user=user).count(),
'recent_interactions': ContactInteraction.objects.filter(
contact__user=user
).count(),
'group_stats': ContactGroup.objects.filter(user=user).annotate(
contact_count=models.Count('contacts')
).values('name', 'contact_count'),
}
return Response(stats)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def export_contacts(request):
"""Export contacts to CSV."""
user = request.user
format_type = request.data.get('format', 'csv')
if format_type == 'csv':
# Create CSV response
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="contacts.csv"'
writer = csv.writer(response)
writer.writerow([
'First Name', 'Last Name', 'Email', 'Phone', 'Company', 'Job Title',
'Address Line 1', 'Address Line 2', 'City', 'State', 'Postal Code',
'Country', 'Website', 'LinkedIn', 'Twitter', 'Facebook', 'Notes',
'Birthday', 'Group', 'Is Favorite', 'Is Blocked'
])
contacts = Contact.objects.filter(user=user).select_related('group')
for contact in contacts:
writer.writerow([
contact.first_name,
contact.last_name,
contact.email,
contact.phone,
contact.company,
contact.job_title,
contact.address_line1,
contact.address_line2,
contact.city,
contact.state,
contact.postal_code,
contact.country,
contact.website,
contact.linkedin,
contact.twitter,
contact.facebook,
contact.notes,
contact.birthday,
contact.group.name if contact.group else '',
contact.is_favorite,
contact.is_blocked,
])
return response
return Response({'error': 'Unsupported format'}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def contact_suggestions(request):
"""Get contact suggestions based on email addresses."""
email = request.query_params.get('email', '')
if not email:
return Response({'suggestions': []})
# Find contacts with similar email addresses
suggestions = Contact.objects.filter(
user=request.user,
email__icontains=email
).values('id', 'first_name', 'last_name', 'email', 'company')[:10]
return Response({'suggestions': list(suggestions)})

BIN
db.sqlite3 Normal file

Binary file not shown.

121
docker-compose.yml Normal file
View File

@@ -0,0 +1,121 @@
version: '3.8'
services:
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_DB=gnxmail
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
web:
build: .
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput &&
gunicorn gnxmail.wsgi:application --bind 0.0.0.0:8000 --workers 3"
volumes:
- .:/app
- static_volume:/app/staticfiles
- media_volume:/app/media
ports:
- "8000:8000"
environment:
- DEBUG=False
- SECRET_KEY=your-secret-key-here
- DB_NAME=gnxmail
- DB_USER=postgres
- DB_PASSWORD=password
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3
celery:
build: .
command: celery -A gnxmail worker -l info
volumes:
- .:/app
- media_volume:/app/media
environment:
- DEBUG=False
- SECRET_KEY=your-secret-key-here
- DB_NAME=gnxmail
- DB_USER=postgres
- DB_PASSWORD=password
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
celery-beat:
build: .
command: celery -A gnxmail beat -l info
volumes:
- .:/app
environment:
- DEBUG=False
- SECRET_KEY=your-secret-key-here
- DB_NAME=gnxmail
- DB_USER=postgres
- DB_PASSWORD=password
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- static_volume:/app/staticfiles
- media_volume:/app/media
depends_on:
- web
volumes:
postgres_data:
static_volume:
media_volume:

0
emails/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

92
emails/admin.py Normal file
View File

@@ -0,0 +1,92 @@
from django.contrib import admin
from .models import (
Email, EmailFolder, EmailAttachment, EmailThread, EmailTemplate,
EmailSignature, EmailRule, EmailSearch
)
@admin.register(EmailFolder)
class EmailFolderAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'folder_type', 'is_system', 'created_at')
list_filter = ('folder_type', 'is_system', 'created_at')
search_fields = ('name', 'user__email')
raw_id_fields = ('user', 'parent')
@admin.register(Email)
class EmailAdmin(admin.ModelAdmin):
list_display = ('subject', 'from_email', 'user', 'folder', 'status', 'is_read', 'created_at')
list_filter = ('status', 'is_read', 'is_starred', 'is_important', 'folder', 'created_at')
search_fields = ('subject', 'from_email', 'body_text', 'user__email')
raw_id_fields = ('user', 'folder')
readonly_fields = ('uuid', 'message_id', 'created_at', 'updated_at')
date_hierarchy = 'created_at'
fieldsets = (
('Basic Information', {
'fields': ('user', 'folder', 'subject', 'from_email')
}),
('Recipients', {
'fields': ('to_emails', 'cc_emails', 'bcc_emails', 'reply_to')
}),
('Content', {
'fields': ('body_text', 'body_html')
}),
('Metadata', {
'fields': ('message_id', 'in_reply_to', 'references', 'priority', 'status')
}),
('Flags', {
'fields': ('is_read', 'is_starred', 'is_important', 'is_encrypted')
}),
('Timestamps', {
'fields': ('sent_at', 'received_at', 'created_at', 'updated_at')
}),
)
@admin.register(EmailAttachment)
class EmailAttachmentAdmin(admin.ModelAdmin):
list_display = ('filename', 'email', 'content_type', 'size', 'created_at')
list_filter = ('content_type', 'is_inline', 'created_at')
search_fields = ('filename', 'email__subject')
raw_id_fields = ('email',)
@admin.register(EmailThread)
class EmailThreadAdmin(admin.ModelAdmin):
list_display = ('subject', 'participants', 'last_activity', 'created_at')
list_filter = ('created_at', 'last_activity')
search_fields = ('subject', 'participants')
filter_horizontal = ('emails',)
@admin.register(EmailTemplate)
class EmailTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'is_public', 'created_at')
list_filter = ('is_public', 'created_at')
search_fields = ('name', 'subject', 'user__email')
raw_id_fields = ('user',)
@admin.register(EmailSignature)
class EmailSignatureAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'is_default', 'created_at')
list_filter = ('is_default', 'created_at')
search_fields = ('name', 'user__email')
raw_id_fields = ('user',)
@admin.register(EmailRule)
class EmailRuleAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'is_active', 'condition_field', 'action', 'created_at')
list_filter = ('is_active', 'condition_field', 'action', 'created_at')
search_fields = ('name', 'user__email')
raw_id_fields = ('user',)
@admin.register(EmailSearch)
class EmailSearchAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'created_at')
list_filter = ('created_at',)
search_fields = ('name', 'user__email')
raw_id_fields = ('user',)

6
emails/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'emails'

View File

@@ -0,0 +1,201 @@
# Generated by Django 4.2.7 on 2025-09-14 20:10
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Email',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('subject', models.CharField(max_length=500)),
('from_email', models.EmailField(max_length=254)),
('to_emails', models.JSONField(default=list)),
('cc_emails', models.JSONField(default=list)),
('bcc_emails', models.JSONField(default=list)),
('reply_to', models.EmailField(blank=True, max_length=254, null=True)),
('body_text', models.TextField(blank=True)),
('body_html', models.TextField(blank=True)),
('message_id', models.CharField(blank=True, max_length=500, null=True, unique=True)),
('in_reply_to', models.CharField(blank=True, max_length=500, null=True)),
('references', models.TextField(blank=True)),
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High')], default='normal', max_length=10)),
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('received', 'Received'), ('failed', 'Failed')], default='draft', max_length=20)),
('is_read', models.BooleanField(default=False)),
('is_starred', models.BooleanField(default=False)),
('is_important', models.BooleanField(default=False)),
('is_encrypted', models.BooleanField(default=False)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('received_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('size', models.BigIntegerField(default=0)),
],
options={
'db_table': 'emails',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EmailThread',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=500)),
('participants', models.JSONField(default=list)),
('last_activity', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('emails', models.ManyToManyField(related_name='threads', to='emails.email')),
],
options={
'db_table': 'email_threads',
'ordering': ['-last_activity'],
},
),
migrations.CreateModel(
name='EmailSignature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('content_html', models.TextField()),
('content_text', models.TextField(blank=True)),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_signatures', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_signatures',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='EmailRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('condition_field', models.CharField(max_length=50)),
('condition_operator', models.CharField(max_length=20)),
('condition_value', models.CharField(max_length=500)),
('action', models.CharField(choices=[('move', 'Move to folder'), ('mark_read', 'Mark as read'), ('mark_important', 'Mark as important'), ('forward', 'Forward to'), ('delete', 'Delete'), ('reply', 'Auto-reply')], max_length=20)),
('action_value', models.CharField(blank=True, max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_rules', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_rules',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='EmailFolder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('folder_type', models.CharField(choices=[('inbox', 'Inbox'), ('sent', 'Sent'), ('drafts', 'Drafts'), ('trash', 'Trash'), ('spam', 'Spam'), ('archive', 'Archive'), ('custom', 'Custom')], default='custom', max_length=20)),
('color', models.CharField(default='#007bff', max_length=7)),
('is_system', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='emails.emailfolder')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folders', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_folders',
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
migrations.CreateModel(
name='EmailAttachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('filename', models.CharField(max_length=255)),
('content_type', models.CharField(max_length=100)),
('size', models.BigIntegerField()),
('file', models.FileField(upload_to='email_attachments/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png', 'gif', 'zip', 'rar'])])),
('is_inline', models.BooleanField(default=False)),
('content_id', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='emails.email')),
],
options={
'db_table': 'email_attachments',
'ordering': ['filename'],
},
),
migrations.AddField(
model_name='email',
name='folder',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to='emails.emailfolder'),
),
migrations.AddField(
model_name='email',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='EmailTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('subject', models.CharField(max_length=500)),
('body_html', models.TextField()),
('body_text', models.TextField(blank=True)),
('is_public', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_templates',
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
migrations.CreateModel(
name='EmailSearch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('query', models.JSONField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_searches', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'email_searches',
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
migrations.AddIndex(
model_name='email',
index=models.Index(fields=['user', 'folder', '-created_at'], name='emails_user_id_ceecc1_idx'),
),
migrations.AddIndex(
model_name='email',
index=models.Index(fields=['user', 'is_read'], name='emails_user_id_005dbe_idx'),
),
migrations.AddIndex(
model_name='email',
index=models.Index(fields=['user', 'is_starred'], name='emails_user_id_11204a_idx'),
),
migrations.AddIndex(
model_name='email',
index=models.Index(fields=['message_id'], name='emails_message_7b58ef_idx'),
),
]

View File

Binary file not shown.

265
emails/models.py Normal file
View File

@@ -0,0 +1,265 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.core.validators import FileExtensionValidator
import uuid
User = get_user_model()
class EmailFolder(models.Model):
"""Email folders for organizing emails."""
FOLDER_TYPES = [
('inbox', 'Inbox'),
('sent', 'Sent'),
('drafts', 'Drafts'),
('trash', 'Trash'),
('spam', 'Spam'),
('archive', 'Archive'),
('custom', 'Custom'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='folders')
name = models.CharField(max_length=100)
folder_type = models.CharField(max_length=20, choices=FOLDER_TYPES, default='custom')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
color = models.CharField(max_length=7, default='#007bff') # Hex color
is_system = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'email_folders'
unique_together = ['user', 'name']
ordering = ['name']
def __str__(self):
return f"{self.user.email} - {self.name}"
class Email(models.Model):
"""Email model for storing email messages."""
PRIORITY_CHOICES = [
('low', 'Low'),
('normal', 'Normal'),
('high', 'High'),
]
STATUS_CHOICES = [
('draft', 'Draft'),
('sent', 'Sent'),
('received', 'Received'),
('failed', 'Failed'),
]
# Basic email information
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='emails')
folder = models.ForeignKey(EmailFolder, on_delete=models.CASCADE, related_name='emails')
# Email headers
subject = models.CharField(max_length=500)
from_email = models.EmailField()
to_emails = models.JSONField(default=list) # List of email addresses
cc_emails = models.JSONField(default=list) # List of email addresses
bcc_emails = models.JSONField(default=list) # List of email addresses
reply_to = models.EmailField(null=True, blank=True)
# Email content
body_text = models.TextField(blank=True)
body_html = models.TextField(blank=True)
# Email metadata
message_id = models.CharField(max_length=500, unique=True, null=True, blank=True)
in_reply_to = models.CharField(max_length=500, null=True, blank=True)
references = models.TextField(blank=True)
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='normal')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
# Flags and status
is_read = models.BooleanField(default=False)
is_starred = models.BooleanField(default=False)
is_important = models.BooleanField(default=False)
is_encrypted = models.BooleanField(default=False)
# Timestamps
sent_at = models.DateTimeField(null=True, blank=True)
received_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Size information
size = models.BigIntegerField(default=0) # Size in bytes
class Meta:
db_table = 'emails'
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'folder', '-created_at']),
models.Index(fields=['user', 'is_read']),
models.Index(fields=['user', 'is_starred']),
models.Index(fields=['message_id']),
]
def __str__(self):
return f"{self.subject} - {self.from_email}"
def mark_as_read(self):
"""Mark email as read."""
self.is_read = True
self.save(update_fields=['is_read', 'updated_at'])
def mark_as_unread(self):
"""Mark email as unread."""
self.is_read = False
self.save(update_fields=['is_read', 'updated_at'])
def move_to_folder(self, folder):
"""Move email to another folder."""
self.folder = folder
self.save(update_fields=['folder', 'updated_at'])
def get_recipients(self):
"""Get all recipients (to, cc, bcc)."""
return self.to_emails + self.cc_emails + self.bcc_emails
class EmailAttachment(models.Model):
"""Email attachments."""
email = models.ForeignKey(Email, on_delete=models.CASCADE, related_name='attachments')
filename = models.CharField(max_length=255)
content_type = models.CharField(max_length=100)
size = models.BigIntegerField()
file = models.FileField(
upload_to='email_attachments/',
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png', 'gif', 'zip', 'rar'])]
)
is_inline = models.BooleanField(default=False)
content_id = models.CharField(max_length=100, blank=True) # For inline attachments
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'email_attachments'
ordering = ['filename']
def __str__(self):
return f"{self.email.subject} - {self.filename}"
class EmailThread(models.Model):
"""Email threads for conversation view."""
emails = models.ManyToManyField(Email, related_name='threads')
subject = models.CharField(max_length=500)
participants = models.JSONField(default=list) # List of email addresses
last_activity = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'email_threads'
ordering = ['-last_activity']
def __str__(self):
return f"Thread: {self.subject}"
class EmailTemplate(models.Model):
"""Email templates for quick composition."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_templates')
name = models.CharField(max_length=100)
subject = models.CharField(max_length=500)
body_html = models.TextField()
body_text = models.TextField(blank=True)
is_public = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'email_templates'
unique_together = ['user', 'name']
ordering = ['name']
def __str__(self):
return f"{self.user.email} - {self.name}"
class EmailSignature(models.Model):
"""Email signatures."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_signatures')
name = models.CharField(max_length=100)
content_html = models.TextField()
content_text = models.TextField(blank=True)
is_default = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'email_signatures'
ordering = ['name']
def __str__(self):
return f"{self.user.email} - {self.name}"
def save(self, *args, **kwargs):
if self.is_default:
# Ensure only one default signature per user
EmailSignature.objects.filter(user=self.user, is_default=True).update(is_default=False)
super().save(*args, **kwargs)
class EmailRule(models.Model):
"""Email rules for automatic processing."""
ACTION_CHOICES = [
('move', 'Move to folder'),
('mark_read', 'Mark as read'),
('mark_important', 'Mark as important'),
('forward', 'Forward to'),
('delete', 'Delete'),
('reply', 'Auto-reply'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_rules')
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
# Conditions
condition_field = models.CharField(max_length=50) # from, to, subject, body, etc.
condition_operator = models.CharField(max_length=20) # contains, equals, starts_with, etc.
condition_value = models.CharField(max_length=500)
# Actions
action = models.CharField(max_length=20, choices=ACTION_CHOICES)
action_value = models.CharField(max_length=500, blank=True) # Folder name, email address, etc.
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'email_rules'
ordering = ['name']
def __str__(self):
return f"{self.user.email} - {self.name}"
class EmailSearch(models.Model):
"""Saved email searches."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_searches')
name = models.CharField(max_length=100)
query = models.JSONField() # Search parameters
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'email_searches'
unique_together = ['user', 'name']
ordering = ['name']
def __str__(self):
return f"{self.user.email} - {self.name}"

261
emails/serializers.py Normal file
View File

@@ -0,0 +1,261 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import (
Email, EmailFolder, EmailAttachment, EmailThread, EmailTemplate,
EmailSignature, EmailRule, EmailSearch
)
User = get_user_model()
class EmailFolderSerializer(serializers.ModelSerializer):
"""Serializer for email folders."""
email_count = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
class Meta:
model = EmailFolder
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
def get_email_count(self, obj):
return obj.emails.count()
def get_unread_count(self, obj):
return obj.emails.filter(is_read=False).count()
class EmailAttachmentSerializer(serializers.ModelSerializer):
"""Serializer for email attachments."""
class Meta:
model = EmailAttachment
fields = '__all__'
read_only_fields = ('created_at',)
class EmailSerializer(serializers.ModelSerializer):
"""Serializer for emails."""
attachments = EmailAttachmentSerializer(many=True, read_only=True)
folder_name = serializers.CharField(source='folder.name', read_only=True)
from_name = serializers.SerializerMethodField()
recipients = serializers.SerializerMethodField()
thread_count = serializers.SerializerMethodField()
class Meta:
model = Email
fields = '__all__'
read_only_fields = ('uuid', 'user', 'message_id', 'created_at', 'updated_at')
def get_from_name(self, obj):
# Try to get name from contacts or use email
return obj.from_email
def get_recipients(self, obj):
return {
'to': obj.to_emails,
'cc': obj.cc_emails,
'bcc': obj.bcc_emails,
}
def get_thread_count(self, obj):
return obj.threads.count()
class EmailCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating emails."""
attachments = serializers.ListField(
child=serializers.FileField(),
required=False,
write_only=True
)
class Meta:
model = Email
fields = (
'subject', 'to_emails', 'cc_emails', 'bcc_emails', 'reply_to',
'body_text', 'body_html', 'priority', 'folder', 'attachments'
)
def validate_to_emails(self, value):
if not value:
raise serializers.ValidationError("At least one recipient is required.")
return value
def create(self, validated_data):
attachments_data = validated_data.pop('attachments', [])
user = self.context['request'].user
# Set from_email to user's email
validated_data['from_email'] = user.email
# Create email
email = Email.objects.create(user=user, **validated_data)
# Handle attachments
for attachment_file in attachments_data:
EmailAttachment.objects.create(
email=email,
filename=attachment_file.name,
content_type=attachment_file.content_type,
size=attachment_file.size,
file=attachment_file
)
return email
class EmailReplySerializer(serializers.ModelSerializer):
"""Serializer for replying to emails."""
original_email = serializers.PrimaryKeyRelatedField(queryset=Email.objects.all())
class Meta:
model = Email
fields = ('original_email', 'subject', 'body_text', 'body_html', 'priority')
def create(self, validated_data):
original_email = validated_data.pop('original_email')
user = self.context['request'].user
# Set reply headers
validated_data.update({
'user': user,
'from_email': user.email,
'to_emails': [original_email.from_email],
'in_reply_to': original_email.message_id,
'references': f"{original_email.references} {original_email.message_id}".strip(),
'folder': EmailFolder.objects.get(user=user, folder_type='sent'),
})
# Add "Re: " prefix if not already present
subject = validated_data.get('subject', '')
if not subject.startswith('Re: '):
validated_data['subject'] = f"Re: {subject}"
return Email.objects.create(**validated_data)
class EmailForwardSerializer(serializers.ModelSerializer):
"""Serializer for forwarding emails."""
original_email = serializers.PrimaryKeyRelatedField(queryset=Email.objects.all())
class Meta:
model = Email
fields = ('original_email', 'to_emails', 'cc_emails', 'bcc_emails', 'subject', 'body_text', 'body_html', 'priority')
def create(self, validated_data):
original_email = validated_data.pop('original_email')
user = self.context['request'].user
# Set forward headers
validated_data.update({
'user': user,
'from_email': user.email,
'in_reply_to': original_email.message_id,
'folder': EmailFolder.objects.get(user=user, folder_type='sent'),
})
# Add "Fwd: " prefix if not already present
subject = validated_data.get('subject', '')
if not subject.startswith('Fwd: '):
validated_data['subject'] = f"Fwd: {subject}"
# Add original email content
original_content = f"\n\n--- Forwarded message ---\nFrom: {original_email.from_email}\nDate: {original_email.sent_at}\nSubject: {original_email.subject}\n\n{original_email.body_text}"
validated_data['body_text'] += original_content
return Email.objects.create(**validated_data)
class EmailThreadSerializer(serializers.ModelSerializer):
"""Serializer for email threads."""
emails = EmailSerializer(many=True, read_only=True)
email_count = serializers.SerializerMethodField()
class Meta:
model = EmailThread
fields = '__all__'
def get_email_count(self, obj):
return obj.emails.count()
class EmailTemplateSerializer(serializers.ModelSerializer):
"""Serializer for email templates."""
class Meta:
model = EmailTemplate
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
class EmailSignatureSerializer(serializers.ModelSerializer):
"""Serializer for email signatures."""
class Meta:
model = EmailSignature
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
class EmailRuleSerializer(serializers.ModelSerializer):
"""Serializer for email rules."""
class Meta:
model = EmailRule
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
class EmailSearchSerializer(serializers.ModelSerializer):
"""Serializer for email searches."""
class Meta:
model = EmailSearch
fields = '__all__'
read_only_fields = ('user', 'created_at')
class EmailBulkActionSerializer(serializers.Serializer):
"""Serializer for bulk email actions."""
ACTION_CHOICES = [
('mark_read', 'Mark as read'),
('mark_unread', 'Mark as unread'),
('mark_starred', 'Mark as starred'),
('mark_unstarred', 'Mark as unstarred'),
('mark_important', 'Mark as important'),
('mark_unimportant', 'Mark as unimportant'),
('move_to_folder', 'Move to folder'),
('delete', 'Delete'),
]
email_ids = serializers.ListField(
child=serializers.IntegerField(),
min_length=1
)
action = serializers.ChoiceField(choices=ACTION_CHOICES)
folder_id = serializers.IntegerField(required=False)
def validate_email_ids(self, value):
user = self.context['request'].user
# Verify all emails belong to the user
email_count = Email.objects.filter(id__in=value, user=user).count()
if email_count != len(value):
raise serializers.ValidationError("Some emails don't exist or don't belong to you.")
return value
def validate(self, attrs):
action = attrs.get('action')
folder_id = attrs.get('folder_id')
if action == 'move_to_folder' and not folder_id:
raise serializers.ValidationError("folder_id is required for move_to_folder action.")
return attrs

299
emails/tasks.py Normal file
View File

@@ -0,0 +1,299 @@
from celery import shared_task
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
from django.utils import timezone
import smtplib
import imaplib
import email
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import logging
from .models import Email, EmailFolder, EmailAttachment
from accounts.models import User
logger = logging.getLogger(__name__)
@shared_task
def send_email_task(email_id):
"""Send email asynchronously."""
try:
email_obj = Email.objects.get(id=email_id)
user = email_obj.user
# Create email message
msg = MIMEMultipart('alternative')
msg['From'] = email_obj.from_email
msg['To'] = ', '.join(email_obj.to_emails)
msg['Subject'] = email_obj.subject
if email_obj.cc_emails:
msg['Cc'] = ', '.join(email_obj.cc_emails)
if email_obj.reply_to:
msg['Reply-To'] = email_obj.reply_to
if email_obj.in_reply_to:
msg['In-Reply-To'] = email_obj.in_reply_to
if email_obj.references:
msg['References'] = email_obj.references
# Add body
if email_obj.body_text:
text_part = MIMEText(email_obj.body_text, 'plain')
msg.attach(text_part)
if email_obj.body_html:
html_part = MIMEText(email_obj.body_html, 'html')
msg.attach(html_part)
# Add attachments
for attachment in email_obj.attachments.all():
with open(attachment.file.path, 'rb') as f:
part = MIMEBase('application', 'octet-stream')
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {attachment.filename}'
)
msg.attach(part)
# Send email
if user.smtp_host and user.smtp_username:
# Use user's SMTP settings
server = smtplib.SMTP(user.smtp_host, user.smtp_port)
if user.smtp_use_tls:
server.starttls()
server.login(user.smtp_username, user.get_smtp_password())
recipients = email_obj.to_emails + email_obj.cc_emails + email_obj.bcc_emails
server.send_message(msg, to_addrs=recipients)
server.quit()
else:
# Use Django's email backend
django_email = EmailMultiAlternatives(
subject=email_obj.subject,
body=email_obj.body_text,
from_email=email_obj.from_email,
to=email_obj.to_emails,
cc=email_obj.cc_emails,
bcc=email_obj.bcc_emails,
reply_to=[email_obj.reply_to] if email_obj.reply_to else None,
)
if email_obj.body_html:
django_email.attach_alternative(email_obj.body_html, "text/html")
# Add attachments
for attachment in email_obj.attachments.all():
django_email.attach_file(attachment.file.path)
django_email.send()
# Update email status
email_obj.status = 'sent'
email_obj.sent_at = timezone.now()
email_obj.save()
logger.info(f"Email {email_id} sent successfully")
except Exception as e:
logger.error(f"Failed to send email {email_id}: {str(e)}")
# Update email status to failed
try:
email_obj = Email.objects.get(id=email_id)
email_obj.status = 'failed'
email_obj.save()
except Email.DoesNotExist:
pass
@shared_task
def fetch_emails_task(user_id):
"""Fetch emails from IMAP server asynchronously."""
try:
user = User.objects.get(id=user_id)
if not user.imap_host or not user.imap_username:
logger.error(f"IMAP settings not configured for user {user_id}")
return
# Connect to IMAP server
if user.imap_use_ssl:
server = imaplib.IMAP4_SSL(user.imap_host, user.imap_port)
else:
server = imaplib.IMAP4(user.imap_host, user.imap_port)
server.login(user.imap_username, user.get_imap_password())
# Select inbox
server.select('INBOX')
# Search for unseen emails
status, messages = server.search(None, 'UNSEEN')
if status == 'OK':
email_ids = messages[0].split()
# Get or create inbox folder
inbox_folder, created = EmailFolder.objects.get_or_create(
user=user,
folder_type='inbox',
defaults={'name': 'Inbox', 'is_system': True}
)
for email_id in email_ids:
try:
# Fetch email
status, msg_data = server.fetch(email_id, '(RFC822)')
if status == 'OK':
raw_email = msg_data[0][1]
email_message = email.message_from_bytes(raw_email)
# Parse email
subject = email_message.get('Subject', '')
from_email = email_message.get('From', '')
to_emails = email_message.get('To', '').split(',')
cc_emails = email_message.get('Cc', '').split(',') if email_message.get('Cc') else []
message_id = email_message.get('Message-ID', '')
in_reply_to = email_message.get('In-Reply-To', '')
references = email_message.get('References', '')
# Get email body
body_text = ''
body_html = ''
if email_message.is_multipart():
for part in email_message.walk():
content_type = part.get_content_type()
content_disposition = str(part.get('Content-Disposition'))
if content_type == 'text/plain' and 'attachment' not in content_disposition:
body_text = part.get_payload(decode=True).decode()
elif content_type == 'text/html' and 'attachment' not in content_disposition:
body_html = part.get_payload(decode=True).decode()
else:
content_type = email_message.get_content_type()
if content_type == 'text/plain':
body_text = email_message.get_payload(decode=True).decode()
elif content_type == 'text/html':
body_html = email_message.get_payload(decode=True).decode()
# Create email object
email_obj = Email.objects.create(
user=user,
folder=inbox_folder,
subject=subject,
from_email=from_email,
to_emails=[email.strip() for email in to_emails if email.strip()],
cc_emails=[email.strip() for email in cc_emails if email.strip()],
body_text=body_text,
body_html=body_html,
message_id=message_id,
in_reply_to=in_reply_to,
references=references,
status='received',
received_at=timezone.now(),
size=len(raw_email)
)
# Process attachments
if email_message.is_multipart():
for part in email_message.walk():
content_disposition = str(part.get('Content-Disposition'))
if 'attachment' in content_disposition:
filename = part.get_filename()
if filename:
# Save attachment
attachment = EmailAttachment.objects.create(
email=email_obj,
filename=filename,
content_type=part.get_content_type(),
size=len(part.get_payload(decode=True)),
file=part.get_payload(decode=True)
)
logger.info(f"Fetched email {email_obj.id} for user {user_id}")
except Exception as e:
logger.error(f"Error processing email {email_id}: {str(e)}")
continue
server.logout()
logger.info(f"Email fetch completed for user {user_id}")
except Exception as e:
logger.error(f"Failed to fetch emails for user {user_id}: {str(e)}")
@shared_task
def cleanup_old_emails():
"""Clean up old emails from trash folder."""
try:
from django.utils import timezone
from datetime import timedelta
# Delete emails in trash folder older than 30 days
cutoff_date = timezone.now() - timedelta(days=30)
trash_emails = Email.objects.filter(
folder__folder_type='trash',
created_at__lt=cutoff_date
)
count = trash_emails.count()
trash_emails.delete()
logger.info(f"Cleaned up {count} old emails from trash")
except Exception as e:
logger.error(f"Failed to cleanup old emails: {str(e)}")
@shared_task
def process_email_rules():
"""Process email rules for all users."""
try:
from .models import EmailRule
rules = EmailRule.objects.filter(is_active=True)
for rule in rules:
try:
# Apply rule to matching emails
emails = Email.objects.filter(user=rule.user)
# Apply condition
if rule.condition_field == 'from':
emails = emails.filter(from_email__icontains=rule.condition_value)
elif rule.condition_field == 'to':
emails = emails.filter(to_emails__icontains=rule.condition_value)
elif rule.condition_field == 'subject':
emails = emails.filter(subject__icontains=rule.condition_value)
elif rule.condition_field == 'body':
emails = emails.filter(body_text__icontains=rule.condition_value)
# Apply action
if rule.action == 'move':
folder = EmailFolder.objects.get(user=rule.user, name=rule.action_value)
emails.update(folder=folder)
elif rule.action == 'mark_read':
emails.update(is_read=True)
elif rule.action == 'mark_important':
emails.update(is_important=True)
elif rule.action == 'delete':
emails.delete()
logger.info(f"Processed rule {rule.id} for user {rule.user.id}")
except Exception as e:
logger.error(f"Error processing rule {rule.id}: {str(e)}")
continue
except Exception as e:
logger.error(f"Failed to process email rules: {str(e)}")

39
emails/urls.py Normal file
View File

@@ -0,0 +1,39 @@
from django.urls import path
from . import views
urlpatterns = [
# Email folders
path('folders/', views.EmailFolderListCreateView.as_view(), name='email-folder-list'),
path('folders/<int:pk>/', views.EmailFolderDetailView.as_view(), name='email-folder-detail'),
# Emails
path('', views.EmailListCreateView.as_view(), name='email-list'),
path('<int:pk>/', views.EmailDetailView.as_view(), name='email-detail'),
path('reply/', views.EmailReplyView.as_view(), name='email-reply'),
path('forward/', views.EmailForwardView.as_view(), name='email-forward'),
path('bulk-action/', views.EmailBulkActionView.as_view(), name='email-bulk-action'),
path('search/', views.EmailSearchView.as_view(), name='email-search'),
path('threads/<int:pk>/', views.EmailThreadView.as_view(), name='email-thread'),
# Email actions
path('send/', views.send_email, name='send-email'),
path('fetch/', views.fetch_emails, name='fetch-emails'),
path('stats/', views.email_stats, name='email-stats'),
path('test-settings/', views.test_email_settings, name='test-email-settings'),
# Templates
path('templates/', views.EmailTemplateListCreateView.as_view(), name='email-template-list'),
path('templates/<int:pk>/', views.EmailTemplateDetailView.as_view(), name='email-template-detail'),
# Signatures
path('signatures/', views.EmailSignatureListCreateView.as_view(), name='email-signature-list'),
path('signatures/<int:pk>/', views.EmailSignatureDetailView.as_view(), name='email-signature-detail'),
# Rules
path('rules/', views.EmailRuleListCreateView.as_view(), name='email-rule-list'),
path('rules/<int:pk>/', views.EmailRuleDetailView.as_view(), name='email-rule-detail'),
# Saved searches
path('searches/', views.EmailSearchListCreateView.as_view(), name='email-search-list'),
path('searches/<int:pk>/', views.EmailSearchDetailView.as_view(), name='email-search-detail'),
]

405
emails/views.py Normal file
View File

@@ -0,0 +1,405 @@
from rest_framework import generics, status, permissions, filters
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Q, Count
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django_ratelimit.decorators import ratelimit
import smtplib
import imaplib
import email
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os
from .models import (
Email, EmailFolder, EmailAttachment, EmailThread, EmailTemplate,
EmailSignature, EmailRule, EmailSearch
)
from .serializers import (
EmailSerializer, EmailCreateSerializer, EmailReplySerializer, EmailForwardSerializer,
EmailFolderSerializer, EmailAttachmentSerializer, EmailThreadSerializer,
EmailTemplateSerializer, EmailSignatureSerializer, EmailRuleSerializer,
EmailSearchSerializer, EmailBulkActionSerializer
)
from .tasks import send_email_task, fetch_emails_task
class EmailFolderListCreateView(generics.ListCreateAPIView):
"""List and create email folders."""
serializer_class = EmailFolderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailFolder.objects.filter(user=self.request.user)
class EmailFolderDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete email folder."""
serializer_class = EmailFolderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailFolder.objects.filter(user=self.request.user)
class EmailListCreateView(generics.ListCreateAPIView):
"""List and create emails."""
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['folder', 'is_read', 'is_starred', 'is_important', 'status']
search_fields = ['subject', 'from_email', 'body_text']
ordering_fields = ['created_at', 'sent_at', 'subject']
ordering = ['-created_at']
def get_serializer_class(self):
if self.request.method == 'POST':
return EmailCreateSerializer
return EmailSerializer
def get_queryset(self):
return Email.objects.filter(user=self.request.user).select_related('folder').prefetch_related('attachments')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete email."""
serializer_class = EmailSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Email.objects.filter(user=self.request.user).select_related('folder').prefetch_related('attachments')
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
# Mark as read when retrieved
if not instance.is_read:
instance.mark_as_read()
serializer = self.get_serializer(instance)
return Response(serializer.data)
class EmailReplyView(generics.CreateAPIView):
"""Reply to an email."""
serializer_class = EmailReplySerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailForwardView(generics.CreateAPIView):
"""Forward an email."""
serializer_class = EmailForwardSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailBulkActionView(APIView):
"""Perform bulk actions on emails."""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
serializer = EmailBulkActionSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
email_ids = serializer.validated_data['email_ids']
action = serializer.validated_data['action']
folder_id = serializer.validated_data.get('folder_id')
emails = Email.objects.filter(id__in=email_ids, user=request.user)
if action == 'mark_read':
emails.update(is_read=True)
elif action == 'mark_unread':
emails.update(is_read=False)
elif action == 'mark_starred':
emails.update(is_starred=True)
elif action == 'mark_unstarred':
emails.update(is_starred=False)
elif action == 'mark_important':
emails.update(is_important=True)
elif action == 'mark_unimportant':
emails.update(is_important=False)
elif action == 'move_to_folder':
folder = get_object_or_404(EmailFolder, id=folder_id, user=request.user)
emails.update(folder=folder)
elif action == 'delete':
emails.delete()
return Response({'message': f'Bulk action {action} completed successfully'})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class EmailSearchView(generics.ListAPIView):
"""Search emails with advanced filters."""
serializer_class = EmailSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = Email.objects.filter(user=self.request.user)
# Get search parameters
query = self.request.query_params.get('q', '')
folder = self.request.query_params.get('folder')
is_read = self.request.query_params.get('is_read')
is_starred = self.request.query_params.get('is_starred')
is_important = self.request.query_params.get('is_important')
date_from = self.request.query_params.get('date_from')
date_to = self.request.query_params.get('date_to')
# Apply filters
if query:
queryset = queryset.filter(
Q(subject__icontains=query) |
Q(from_email__icontains=query) |
Q(body_text__icontains=query)
)
if folder:
queryset = queryset.filter(folder_id=folder)
if is_read is not None:
queryset = queryset.filter(is_read=is_read.lower() == 'true')
if is_starred is not None:
queryset = queryset.filter(is_starred=is_starred.lower() == 'true')
if is_important is not None:
queryset = queryset.filter(is_important=is_important.lower() == 'true')
if date_from:
queryset = queryset.filter(created_at__gte=date_from)
if date_to:
queryset = queryset.filter(created_at__lte=date_to)
return queryset.select_related('folder').prefetch_related('attachments')
class EmailThreadView(generics.RetrieveAPIView):
"""Get email thread."""
serializer_class = EmailThreadSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailThread.objects.filter(emails__user=self.request.user).distinct()
class EmailTemplateListCreateView(generics.ListCreateAPIView):
"""List and create email templates."""
serializer_class = EmailTemplateSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailTemplate.objects.filter(
Q(user=self.request.user) | Q(is_public=True)
).order_by('name')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailTemplateDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete email template."""
serializer_class = EmailTemplateSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailTemplate.objects.filter(user=self.request.user)
class EmailSignatureListCreateView(generics.ListCreateAPIView):
"""List and create email signatures."""
serializer_class = EmailSignatureSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailSignature.objects.filter(user=self.request.user).order_by('name')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailSignatureDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete email signature."""
serializer_class = EmailSignatureSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailSignature.objects.filter(user=self.request.user)
class EmailRuleListCreateView(generics.ListCreateAPIView):
"""List and create email rules."""
serializer_class = EmailRuleSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailRule.objects.filter(user=self.request.user).order_by('name')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailRuleDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete email rule."""
serializer_class = EmailRuleSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailRule.objects.filter(user=self.request.user)
class EmailSearchListCreateView(generics.ListCreateAPIView):
"""List and create saved email searches."""
serializer_class = EmailSearchSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailSearch.objects.filter(user=self.request.user).order_by('name')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class EmailSearchDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete saved email search."""
serializer_class = EmailSearchSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return EmailSearch.objects.filter(user=self.request.user)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
@ratelimit(key='user', rate='10/m', method=['POST'])
def send_email(request):
"""Send email endpoint."""
serializer = EmailCreateSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
email = serializer.save()
# Send email asynchronously
send_email_task.delay(email.id)
return Response({
'message': 'Email queued for sending',
'email': EmailSerializer(email).data
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
@ratelimit(key='user', rate='5/m', method=['POST'])
def fetch_emails(request):
"""Fetch emails from IMAP server."""
user = request.user
# Check if user has IMAP settings configured
if not user.imap_host or not user.imap_username:
return Response(
{'error': 'IMAP settings not configured'},
status=status.HTTP_400_BAD_REQUEST
)
# Fetch emails asynchronously
fetch_emails_task.delay(user.id)
return Response({'message': 'Email fetch started'}, status=status.HTTP_200_OK)
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def email_stats(request):
"""Get email statistics."""
user = request.user
stats = {
'total_emails': Email.objects.filter(user=user).count(),
'unread_emails': Email.objects.filter(user=user, is_read=False).count(),
'starred_emails': Email.objects.filter(user=user, is_starred=True).count(),
'important_emails': Email.objects.filter(user=user, is_important=True).count(),
'sent_emails': Email.objects.filter(user=user, status='sent').count(),
'draft_emails': Email.objects.filter(user=user, status='draft').count(),
'folder_stats': EmailFolder.objects.filter(user=user).annotate(
email_count=Count('emails'),
unread_count=Count('emails', filter=Q(emails__is_read=False))
).values('name', 'email_count', 'unread_count'),
}
return Response(stats)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def test_email_settings(request):
"""Test email server settings."""
user = request.user
settings_type = request.data.get('type', 'smtp') # smtp or imap
try:
if settings_type == 'smtp':
# Test SMTP connection
if not user.smtp_host or not user.smtp_username:
return Response(
{'error': 'SMTP settings not configured'},
status=status.HTTP_400_BAD_REQUEST
)
server = smtplib.SMTP(user.smtp_host, user.smtp_port)
if user.smtp_use_tls:
server.starttls()
server.login(user.smtp_username, user.get_smtp_password())
server.quit()
elif settings_type == 'imap':
# Test IMAP connection
if not user.imap_host or not user.imap_username:
return Response(
{'error': 'IMAP settings not configured'},
status=status.HTTP_400_BAD_REQUEST
)
if user.imap_use_ssl:
server = imaplib.IMAP4_SSL(user.imap_host, user.imap_port)
else:
server = imaplib.IMAP4(user.imap_host, user.imap_port)
server.login(user.imap_username, user.get_imap_password())
server.logout()
return Response({'message': f'{settings_type.upper()} connection successful'})
except Exception as e:
return Response(
{'error': f'{settings_type.upper()} connection failed: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)

29
env.example Normal file
View File

@@ -0,0 +1,29 @@
# Django Settings
SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Database Settings
DB_NAME=gnxmail
DB_USER=postgres
DB_PASSWORD=password
DB_HOST=localhost
DB_PORT=5432
# Email Settings
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
DEFAULT_FROM_EMAIL=noreply@gnxmail.com
# Celery Settings
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Encryption Key for passwords
ENCRYPTION_KEY=your-encryption-key-here
# Frontend URL
REACT_APP_API_URL=http://localhost:8000/api

0
folders/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

37
folders/admin.py Normal file
View File

@@ -0,0 +1,37 @@
from django.contrib import admin
from .models import FolderStructure, FolderPermission, FolderBookmark, FolderActivity
@admin.register(FolderStructure)
class FolderStructureAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'parent', 'folder_type', 'is_system', 'is_shared', 'created_at')
list_filter = ('folder_type', 'is_system', 'is_shared', 'created_at')
search_fields = ('name', 'user__email')
raw_id_fields = ('user', 'parent')
date_hierarchy = 'created_at'
@admin.register(FolderPermission)
class FolderPermissionAdmin(admin.ModelAdmin):
list_display = ('folder', 'user', 'permission', 'granted_by', 'granted_at')
list_filter = ('permission', 'granted_at')
search_fields = ('folder__name', 'user__email', 'granted_by__email')
raw_id_fields = ('folder', 'user', 'granted_by')
@admin.register(FolderBookmark)
class FolderBookmarkAdmin(admin.ModelAdmin):
list_display = ('user', 'folder', 'created_at')
list_filter = ('created_at',)
search_fields = ('user__email', 'folder__name')
raw_id_fields = ('user', 'folder')
@admin.register(FolderActivity)
class FolderActivityAdmin(admin.ModelAdmin):
list_display = ('folder', 'user', 'activity_type', 'created_at')
list_filter = ('activity_type', 'created_at')
search_fields = ('folder__name', 'user__email', 'description')
raw_id_fields = ('folder', 'user')
readonly_fields = ('created_at',)
date_hierarchy = 'created_at'

6
folders/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FoldersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'folders'

View File

@@ -0,0 +1,84 @@
# Generated by Django 4.2.7 on 2025-09-14 20:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FolderStructure',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('folder_type', models.CharField(default='custom', max_length=20)),
('color', models.CharField(default='#007bff', max_length=7)),
('icon', models.CharField(default='folder', max_length=50)),
('is_system', models.BooleanField(default=False)),
('is_shared', models.BooleanField(default=False)),
('sort_order', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='folders.folderstructure')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_structures', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'folder_structures',
'ordering': ['sort_order', 'name'],
'unique_together': {('user', 'name', 'parent')},
},
),
migrations.CreateModel(
name='FolderActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('activity_type', models.CharField(choices=[('created', 'Created'), ('updated', 'Updated'), ('deleted', 'Deleted'), ('shared', 'Shared'), ('unshared', 'Unshared'), ('moved', 'Moved'), ('renamed', 'Renamed')], max_length=20)),
('description', models.TextField()),
('metadata', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='folders.folderstructure')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_activities', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'folder_activities',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FolderPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('permission', models.CharField(choices=[('read', 'Read'), ('write', 'Write'), ('admin', 'Admin')], max_length=10)),
('granted_at', models.DateTimeField(auto_now_add=True)),
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='folders.folderstructure')),
('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='granted_permissions', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_permissions', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'folder_permissions',
'unique_together': {('folder', 'user')},
},
),
migrations.CreateModel(
name='FolderBookmark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='folders.folderstructure')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_bookmarks', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'folder_bookmarks',
'unique_together': {('user', 'folder')},
},
),
]

View File

117
folders/models.py Normal file
View File

@@ -0,0 +1,117 @@
from django.db import models
from django.contrib.auth import get_user_model
from emails.models import EmailFolder
User = get_user_model()
class FolderStructure(models.Model):
"""Folder structure for organizing emails and other content."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='folder_structures')
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
folder_type = models.CharField(max_length=20, default='custom')
color = models.CharField(max_length=7, default='#007bff')
icon = models.CharField(max_length=50, default='folder')
is_system = models.BooleanField(default=False)
is_shared = models.BooleanField(default=False)
sort_order = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'folder_structures'
unique_together = ['user', 'name', 'parent']
ordering = ['sort_order', 'name']
def __str__(self):
return f"{self.user.email} - {self.name}"
def get_path(self):
"""Get full path of the folder."""
path = [self.name]
parent = self.parent
while parent:
path.insert(0, parent.name)
parent = parent.parent
return ' / '.join(path)
def get_children(self):
"""Get all children folders."""
return self.children.all().order_by('sort_order', 'name')
def get_descendants(self):
"""Get all descendant folders."""
descendants = []
for child in self.get_children():
descendants.append(child)
descendants.extend(child.get_descendants())
return descendants
class FolderPermission(models.Model):
"""Permissions for shared folders."""
PERMISSION_CHOICES = [
('read', 'Read'),
('write', 'Write'),
('admin', 'Admin'),
]
folder = models.ForeignKey(FolderStructure, on_delete=models.CASCADE, related_name='permissions')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='folder_permissions')
permission = models.CharField(max_length=10, choices=PERMISSION_CHOICES)
granted_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='granted_permissions')
granted_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'folder_permissions'
unique_together = ['folder', 'user']
def __str__(self):
return f"{self.folder.name} - {self.user.email} ({self.permission})"
class FolderBookmark(models.Model):
"""Bookmarks for quick access to folders."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='folder_bookmarks')
folder = models.ForeignKey(FolderStructure, on_delete=models.CASCADE, related_name='bookmarks')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'folder_bookmarks'
unique_together = ['user', 'folder']
def __str__(self):
return f"{self.user.email} - {self.folder.name}"
class FolderActivity(models.Model):
"""Activity log for folders."""
ACTIVITY_TYPES = [
('created', 'Created'),
('updated', 'Updated'),
('deleted', 'Deleted'),
('shared', 'Shared'),
('unshared', 'Unshared'),
('moved', 'Moved'),
('renamed', 'Renamed'),
]
folder = models.ForeignKey(FolderStructure, on_delete=models.CASCADE, related_name='activities')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='folder_activities')
activity_type = models.CharField(max_length=20, choices=ACTIVITY_TYPES)
description = models.TextField()
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'folder_activities'
ordering = ['-created_at']
def __str__(self):
return f"{self.folder.name} - {self.get_activity_type_display()} by {self.user.email}"

134
folders/serializers.py Normal file
View File

@@ -0,0 +1,134 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import FolderStructure, FolderPermission, FolderBookmark, FolderActivity
User = get_user_model()
class FolderStructureSerializer(serializers.ModelSerializer):
"""Serializer for folder structures."""
path = serializers.CharField(source='get_path', read_only=True)
children = serializers.SerializerMethodField()
email_count = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
class Meta:
model = FolderStructure
fields = '__all__'
read_only_fields = ('user', 'created_at', 'updated_at')
def get_children(self, obj):
children = obj.get_children()
return FolderStructureSerializer(children, many=True, context=self.context).data
def get_email_count(self, obj):
# This would need to be implemented based on your email folder relationship
return 0
def get_unread_count(self, obj):
# This would need to be implemented based on your email folder relationship
return 0
def get_permissions(self, obj):
if obj.is_shared:
permissions = obj.permissions.all()
return FolderPermissionSerializer(permissions, many=True).data
return []
class FolderPermissionSerializer(serializers.ModelSerializer):
"""Serializer for folder permissions."""
user_name = serializers.CharField(source='user.get_full_name', read_only=True)
user_email = serializers.CharField(source='user.email', read_only=True)
granted_by_name = serializers.CharField(source='granted_by.get_full_name', read_only=True)
class Meta:
model = FolderPermission
fields = '__all__'
read_only_fields = ('granted_by', 'granted_at')
class FolderBookmarkSerializer(serializers.ModelSerializer):
"""Serializer for folder bookmarks."""
folder_name = serializers.CharField(source='folder.name', read_only=True)
folder_path = serializers.CharField(source='folder.get_path', read_only=True)
class Meta:
model = FolderBookmark
fields = '__all__'
read_only_fields = ('user', 'created_at')
class FolderActivitySerializer(serializers.ModelSerializer):
"""Serializer for folder activities."""
user_name = serializers.CharField(source='user.get_full_name', read_only=True)
user_email = serializers.CharField(source='user.email', read_only=True)
folder_name = serializers.CharField(source='folder.name', read_only=True)
class Meta:
model = FolderActivity
fields = '__all__'
read_only_fields = ('created_at',)
class FolderCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating folders."""
class Meta:
model = FolderStructure
fields = ('name', 'description', 'parent', 'folder_type', 'color', 'icon', 'sort_order')
def validate_name(self, value):
user = self.context['request'].user
parent = self.initial_data.get('parent')
# Check for duplicate names in the same parent folder
if FolderStructure.objects.filter(
user=user,
name=value,
parent_id=parent
).exists():
raise serializers.ValidationError("A folder with this name already exists in the same location.")
return value
class FolderShareSerializer(serializers.Serializer):
"""Serializer for sharing folders."""
user_email = serializers.EmailField()
permission = serializers.ChoiceField(choices=FolderPermission.PERMISSION_CHOICES)
def validate_user_email(self, value):
user = self.context['request'].user
if value == user.email:
raise serializers.ValidationError("You cannot share a folder with yourself.")
try:
User.objects.get(email=value)
except User.DoesNotExist:
raise serializers.ValidationError("User with this email does not exist.")
return value
class FolderMoveSerializer(serializers.Serializer):
"""Serializer for moving folders."""
new_parent = serializers.IntegerField(required=False, allow_null=True)
new_sort_order = serializers.IntegerField(required=False)
def validate_new_parent(self, value):
if value is not None:
user = self.context['request'].user
try:
folder = FolderStructure.objects.get(id=value, user=user)
return folder
except FolderStructure.DoesNotExist:
raise serializers.ValidationError("Parent folder does not exist or you don't have access to it.")
return None

21
folders/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from . import views
urlpatterns = [
# Folders
path('', views.FolderListCreateView.as_view(), name='folder-list'),
path('tree/', views.FolderTreeView.as_view(), name='folder-tree'),
path('<int:pk>/', views.FolderDetailView.as_view(), name='folder-detail'),
path('<int:folder_id>/share/', views.share_folder, name='share-folder'),
path('<int:folder_id>/move/', views.move_folder, name='move-folder'),
path('<int:folder_id>/activities/', views.FolderActivityView.as_view(), name='folder-activities'),
path('stats/', views.folder_stats, name='folder-stats'),
# Folder permissions
path('<int:folder_id>/permissions/', views.FolderPermissionListCreateView.as_view(), name='folder-permission-list'),
path('<int:folder_id>/permissions/<int:pk>/', views.FolderPermissionDetailView.as_view(), name='folder-permission-detail'),
# Folder bookmarks
path('<int:folder_id>/bookmarks/', views.FolderBookmarkListCreateView.as_view(), name='folder-bookmark-list'),
path('bookmarks/<int:pk>/', views.FolderBookmarkDetailView.as_view(), name='folder-bookmark-detail'),
]

361
folders/views.py Normal file
View File

@@ -0,0 +1,361 @@
from rest_framework import generics, status, permissions
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from django.db.models import Q
from django.contrib.auth import get_user_model
from .models import FolderStructure, FolderPermission, FolderBookmark, FolderActivity
from .serializers import (
FolderStructureSerializer, FolderPermissionSerializer, FolderBookmarkSerializer,
FolderActivitySerializer, FolderCreateSerializer, FolderShareSerializer,
FolderMoveSerializer
)
User = get_user_model()
class FolderListCreateView(generics.ListCreateAPIView):
"""List and create folders."""
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.request.method == 'POST':
return FolderCreateSerializer
return FolderStructureSerializer
def get_queryset(self):
user = self.request.user
return FolderStructure.objects.filter(
Q(user=user) | Q(permissions__user=user)
).distinct().order_by('sort_order', 'name')
def perform_create(self, serializer):
folder = serializer.save(user=self.request.user)
# Log activity
FolderActivity.objects.create(
folder=folder,
user=self.request.user,
activity_type='created',
description=f"Created folder '{folder.name}'"
)
class FolderDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete folder."""
serializer_class = FolderStructureSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return FolderStructure.objects.filter(
Q(user=user) | Q(permissions__user=user)
).distinct()
def perform_update(self, serializer):
folder = self.get_object()
old_name = folder.name
folder = serializer.save()
# Log activity
if old_name != folder.name:
FolderActivity.objects.create(
folder=folder,
user=self.request.user,
activity_type='renamed',
description=f"Renamed folder from '{old_name}' to '{folder.name}'"
)
else:
FolderActivity.objects.create(
folder=folder,
user=self.request.user,
activity_type='updated',
description=f"Updated folder '{folder.name}'"
)
def perform_destroy(self, instance):
# Log activity
FolderActivity.objects.create(
folder=instance,
user=self.request.user,
activity_type='deleted',
description=f"Deleted folder '{instance.name}'"
)
instance.delete()
class FolderTreeView(generics.ListAPIView):
"""Get folder tree structure."""
serializer_class = FolderStructureSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return FolderStructure.objects.filter(
Q(user=user) | Q(permissions__user=user),
parent__isnull=True
).distinct().order_by('sort_order', 'name')
class FolderPermissionListCreateView(generics.ListCreateAPIView):
"""List and create folder permissions."""
serializer_class = FolderPermissionSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
folder_id = self.kwargs.get('folder_id')
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user owns the folder or has admin permission
if folder.user != self.request.user:
permission = FolderPermission.objects.filter(
folder=folder,
user=self.request.user,
permission='admin'
).exists()
if not permission:
return FolderPermission.objects.none()
return FolderPermission.objects.filter(folder=folder)
def perform_create(self, serializer):
folder_id = self.kwargs.get('folder_id')
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user owns the folder or has admin permission
if folder.user != self.request.user:
permission = FolderPermission.objects.filter(
folder=folder,
user=self.request.user,
permission='admin'
).exists()
if not permission:
raise PermissionError("You don't have permission to share this folder.")
# Get user to share with
user_email = serializer.validated_data['user_email']
user_to_share = get_object_or_404(User, email=user_email)
# Create permission
permission = FolderPermission.objects.create(
folder=folder,
user=user_to_share,
permission=serializer.validated_data['permission'],
granted_by=self.request.user
)
# Log activity
FolderActivity.objects.create(
folder=folder,
user=self.request.user,
activity_type='shared',
description=f"Shared folder '{folder.name}' with {user_email} ({permission.permission} permission)"
)
class FolderPermissionDetailView(generics.RetrieveUpdateDestroyAPIView):
"""Retrieve, update, or delete folder permission."""
serializer_class = FolderPermissionSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
folder_id = self.kwargs.get('folder_id')
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user owns the folder or has admin permission
if folder.user != self.request.user:
permission = FolderPermission.objects.filter(
folder=folder,
user=self.request.user,
permission='admin'
).exists()
if not permission:
return FolderPermission.objects.none()
return FolderPermission.objects.filter(folder=folder)
class FolderBookmarkListCreateView(generics.ListCreateAPIView):
"""List and create folder bookmarks."""
serializer_class = FolderBookmarkSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return FolderBookmark.objects.filter(user=self.request.user)
def perform_create(self, serializer):
folder_id = self.kwargs.get('folder_id')
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user has access to the folder
if folder.user != self.request.user:
permission = FolderPermission.objects.filter(
folder=folder,
user=self.request.user
).exists()
if not permission:
raise PermissionError("You don't have access to this folder.")
serializer.save(user=self.request.user, folder=folder)
class FolderBookmarkDetailView(generics.RetrieveDestroyAPIView):
"""Retrieve or delete folder bookmark."""
serializer_class = FolderBookmarkSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return FolderBookmark.objects.filter(user=self.request.user)
class FolderActivityView(generics.ListAPIView):
"""List folder activities."""
serializer_class = FolderActivitySerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
folder_id = self.kwargs.get('folder_id')
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user has access to the folder
if folder.user != self.request.user:
permission = FolderPermission.objects.filter(
folder=folder,
user=self.request.user
).exists()
if not permission:
return FolderActivity.objects.none()
return FolderActivity.objects.filter(folder=folder)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def share_folder(request, folder_id):
"""Share a folder with another user."""
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user owns the folder
if folder.user != request.user:
return Response(
{'error': 'You can only share folders you own'},
status=status.HTTP_403_FORBIDDEN
)
serializer = FolderShareSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
user_email = serializer.validated_data['user_email']
permission = serializer.validated_data['permission']
try:
user_to_share = User.objects.get(email=user_email)
except User.DoesNotExist:
return Response(
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
# Create or update permission
permission_obj, created = FolderPermission.objects.get_or_create(
folder=folder,
user=user_to_share,
defaults={
'permission': permission,
'granted_by': request.user
}
)
if not created:
permission_obj.permission = permission
permission_obj.granted_by = request.user
permission_obj.save()
# Make folder shared
folder.is_shared = True
folder.save()
# Log activity
FolderActivity.objects.create(
folder=folder,
user=request.user,
activity_type='shared',
description=f"Shared folder '{folder.name}' with {user_email} ({permission} permission)"
)
return Response({
'message': f'Folder shared with {user_email}',
'permission': FolderPermissionSerializer(permission_obj).data
})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def move_folder(request, folder_id):
"""Move a folder to a new location."""
folder = get_object_or_404(FolderStructure, id=folder_id)
# Check if user owns the folder
if folder.user != request.user:
return Response(
{'error': 'You can only move folders you own'},
status=status.HTTP_403_FORBIDDEN
)
serializer = FolderMoveSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
new_parent = serializer.validated_data.get('new_parent')
new_sort_order = serializer.validated_data.get('new_sort_order')
old_parent = folder.parent
old_sort_order = folder.sort_order
# Update folder
folder.parent = new_parent
if new_sort_order is not None:
folder.sort_order = new_sort_order
folder.save()
# Log activity
FolderActivity.objects.create(
folder=folder,
user=request.user,
activity_type='moved',
description=f"Moved folder '{folder.name}' from {old_parent.name if old_parent else 'root'} to {new_parent.name if new_parent else 'root'}"
)
return Response({
'message': 'Folder moved successfully',
'folder': FolderStructureSerializer(folder).data
})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def folder_stats(request):
"""Get folder statistics."""
user = request.user
stats = {
'total_folders': FolderStructure.objects.filter(user=user).count(),
'shared_folders': FolderStructure.objects.filter(user=user, is_shared=True).count(),
'bookmarked_folders': FolderBookmark.objects.filter(user=user).count(),
'recent_activities': FolderActivity.objects.filter(
Q(folder__user=user) | Q(folder__permissions__user=user)
).distinct().count(),
}
return Response(stats)

1
frontend/node_modules/.bin/acorn generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../acorn/bin/acorn

1
frontend/node_modules/.bin/ansi-html generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../ansi-html/bin/ansi-html

1
frontend/node_modules/.bin/autoprefixer generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../autoprefixer/bin/autoprefixer

1
frontend/node_modules/.bin/baseline-browser-mapping generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.js

1
frontend/node_modules/.bin/browserslist generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../browserslist/cli.js

1
frontend/node_modules/.bin/css-blank-pseudo generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../css-blank-pseudo/dist/cli.cjs

1
frontend/node_modules/.bin/css-has-pseudo generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../css-has-pseudo/dist/cli.cjs

1
frontend/node_modules/.bin/css-prefers-color-scheme generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../css-prefers-color-scheme/dist/cli.cjs

1
frontend/node_modules/.bin/cssesc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../cssesc/bin/cssesc

1
frontend/node_modules/.bin/detect generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../detect-port-alt/bin/detect-port

1
frontend/node_modules/.bin/detect-port generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../detect-port-alt/bin/detect-port

1
frontend/node_modules/.bin/ejs generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../ejs/bin/cli.js

1
frontend/node_modules/.bin/escodegen generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../escodegen/bin/escodegen.js

1
frontend/node_modules/.bin/esgenerate generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../escodegen/bin/esgenerate.js

1
frontend/node_modules/.bin/eslint generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../eslint/bin/eslint.js

1
frontend/node_modules/.bin/esparse generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esprima/bin/esparse.js

1
frontend/node_modules/.bin/esvalidate generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esprima/bin/esvalidate.js

1
frontend/node_modules/.bin/he generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../he/bin/he

1
frontend/node_modules/.bin/html-minifier-terser generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../html-minifier-terser/cli.js

Some files were not shown because too many files have changed in this diff Show More