lots of progress on input processing
This commit is contained in:
0
.clj-kondo/.cache/v1/lock
Normal file
0
.clj-kondo/.cache/v1/lock
Normal file
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/email_organizer_dev
|
||||
OPENAI_API_KEY=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
|
||||
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENAI_MODEL=qwen/qwen3-coder
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DATABASE_URL=postgresql://localhost/email_organizer_dev
|
||||
1
.lsp/.cache/db.transit.json
Normal file
1
.lsp/.cache/db.transit.json
Normal file
@@ -0,0 +1 @@
|
||||
["^ ","~:classpath",["~#set",[]],"~:project-hash","","~:project-root","/home/notid/dev/email-organizer","~:kondo-config-hash","eb356e786b9f206f8976999e79eb26b3af5b77cb757f0a6c4e47bbab24b98297","~:dependency-scheme","jar","~:analysis",null,"~:analysis-checksums",["^ "],"~:project-analysis-type","~:project-and-full-dependencies","~:version",12,"~:stubs-generation-namespaces",["^1",[]]]
|
||||
113
README.md
113
README.md
@@ -1,38 +1,87 @@
|
||||
Email Organizer
|
||||
# Email Organizer
|
||||
|
||||
This repository is a self-hosted version of our paid offering for organizing your email with AI. Simply run it, connect it to your IMAP server, explain in natural language how you want your mail organized, and forget it!
|
||||
A self-hosted AI-powered email organization system that automates folder sorting, prioritization, and rule recommendations through natural language configuration.
|
||||
|
||||
Key problems we solve:
|
||||
1. No more hand writing email sieve/filter rules!
|
||||
1. Separate out marketing emails from receipts / transaction confirmations.
|
||||
1. Automatically star high importance emails based off learning your own preference.
|
||||
1. Automatically recommend new folders/organization rules based off your own activity. For example, if you have a social media folder, and you start using tiktok, this service can recommend adding that as an organization rule.
|
||||
## Core Value Proposition
|
||||
- **Natural Language Rules**: Define email organization logic in plain English instead of writing sieve/filter rules
|
||||
- **Smart Separation**: Automatically categorize marketing emails, receipts, and transactional messages
|
||||
- **Adaptive Intelligence**: Learns your preferences to star important emails and suggest new organizational patterns
|
||||
|
||||
Roadmap:
|
||||
1. [ ] Prototype - Setting up the base infrastructure and user experience
|
||||
1. [ ] MVP - enter your imap server. You list out folders and rules, and once a day emails in the "Pending" folder will be reorganized.
|
||||
1. [ ] "Import" your list of folders from your imap server
|
||||
1. [ ] Polling every 5 minutes, working off recent emails since last poll
|
||||
1. [ ] Label support for services like gmail
|
||||
1. [ ] Generate config to automate integration into imap servers like dovecot or gmail or proton
|
||||
1. [ ] Supporting auth / multi user. Admin controls the list of users, users control their folders.
|
||||
1. [ ] Making a paid, hosted version
|
||||
1. [ ] Auth via google / facebook / etc.
|
||||
1. [ ] Automatically star based off of your habits
|
||||
## Technical Overview
|
||||
**Stack**:
|
||||
- Backend: Flask (Python 3.10+)
|
||||
- Frontend: HTMX + AlpineJS + DaisyUI (Tailwind CSS)
|
||||
- Database: PostgreSQL
|
||||
- AI: OpenAI-compatible API endpoints
|
||||
|
||||
Architectural concepts:
|
||||
1. The web application is built using Flask for a web server.
|
||||
1. HTMX is used to give SPA-like experience.
|
||||
1. AlpineJS is used for anything
|
||||
1. DaisyUI is used (on top of tailwind css) for the front end experience.
|
||||
1. Everything is stored in postgres
|
||||
1. OpenAI-compatible endpoints are used for email classification.
|
||||
**Key Components**:
|
||||
1. **Rule Engine**: Converts natural language rules → executable classification logic
|
||||
2. **Email Processor**: Polls IMAP server, applies AI classifications, moves messages
|
||||
3. **Recommendation System**: Analyzes usage patterns to suggest new folders/rules
|
||||
|
||||
How it works:
|
||||
1. The user interface allows the user to specify a list of folders, and in plain language what content should go in those folders
|
||||
1. When an email is considered, a prompt is generated from all of the user input, specifying the rules, as well as a detailed system prompt.
|
||||
1. If an email cannot be organized, it's left in place
|
||||
## Getting Started
|
||||
### Prerequisites
|
||||
- Python 3.10+
|
||||
- PostgreSQL 14+
|
||||
- IMAP server access
|
||||
|
||||
Initial data model:
|
||||
1. Users (id | user_name | salted_password_hash | imap_server | imap_password )
|
||||
1. Folders (id | user_id | organization_rule | priority)
|
||||
### Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env
|
||||
# (Edit .env with your DB/IMAP credentials)
|
||||
|
||||
# Initialize database
|
||||
flask db upgrade
|
||||
|
||||
# Run development server
|
||||
flask run
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
### Milestone 1: Prototype (Current Focus)
|
||||
- [ ] Core infrastructure setup
|
||||
- [ ] Basic UI for rule configuration
|
||||
- [ ] Mock email processing pipeline
|
||||
- [ ] Database schema implementation
|
||||
|
||||
### Future Milestones
|
||||
- [ ] MVP - enter your imap server. You list out folders and rules, and once a day emails in the "Pending" folder will be reorganized.
|
||||
- [ ] "Import" your list of folders from your imap server
|
||||
- [ ] Polling every 5 minutes, working off recent emails since last poll
|
||||
- [ ] Label support for services like gmail
|
||||
- [ ] Generate config to automate integration into imap servers like dovecot or gmail or proton
|
||||
- [ ] Supporting auth / multi user. Admin controls the list of users, users control their folders.
|
||||
- [ ] Making a paid, hosted version
|
||||
- [ ] Auth via google / facebook / etc.
|
||||
- [ ] Automatically star based off of your habits
|
||||
|
||||
## Data Model
|
||||
**Users**
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID | Primary key |
|
||||
| email | VARCHAR | Unique identifier |
|
||||
| password_hash | BYTEA | Argon2-hashed |
|
||||
| imap_config | JSONB | Encrypted server settings |
|
||||
|
||||
**Folders**
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID | Primary key |
|
||||
| user_id | UUID | Foreign key |
|
||||
| name | VARCHAR | Display name |
|
||||
| rule_text | TEXT | Natural language rule |
|
||||
| priority | INT | Processing order |
|
||||
|
||||
## Contributing
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
> **Note**: All new features require corresponding unit tests and documentation updates.
|
||||
BIN
__pycache__/config.cpython-310.pyc
Normal file
BIN
__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
__pycache__/manage.cpython-310.pyc
Normal file
BIN
__pycache__/manage.cpython-310.pyc
Normal file
Binary file not shown.
148
alembic.ini
Normal file
148
alembic.ini
Normal file
@@ -0,0 +1,148 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
# sqlalchemy.url will be read from the Flask app config
|
||||
sqlalchemy.url =
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
16
app/__init__.py
Normal file
16
app/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from config import config
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
from app.routes import main
|
||||
app.register_blueprint(main)
|
||||
|
||||
return app
|
||||
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/models.cpython-310.pyc
Normal file
BIN
app/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/routes.cpython-310.pyc
Normal file
BIN
app/__pycache__/routes.cpython-310.pyc
Normal file
Binary file not shown.
21
app/models.py
Normal file
21
app/models.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from app import db
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False)
|
||||
# Placeholders for Milestone 1
|
||||
password_hash = db.Column(db.LargeBinary)
|
||||
imap_config = db.Column(db.JSON) # Using db.JSON instead of db.JSONB for compatibility
|
||||
|
||||
class Folder(db.Model):
|
||||
__tablename__ = 'folders'
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
rule_text = db.Column(db.Text)
|
||||
priority = db.Column(db.Integer)
|
||||
|
||||
user = db.relationship('User', backref=db.backref('folders', lazy=True))
|
||||
58
app/routes.py
Normal file
58
app/routes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
from app import db
|
||||
from app.models import Folder, User
|
||||
import uuid
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
# For prototype, use a fixed user ID
|
||||
MOCK_USER_ID = '123e4567-e89b-12d3-a456-426614174000'
|
||||
|
||||
@main.route('/')
|
||||
def index():
|
||||
# Ensure the mock user exists
|
||||
user = User.query.get(MOCK_USER_ID)
|
||||
if not user:
|
||||
user = User(id=MOCK_USER_ID, email='prototype@example.com')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
return render_template('index.html', folders=folders)
|
||||
|
||||
@main.route('/api/folders', methods=['POST'])
|
||||
def add_folder():
|
||||
try:
|
||||
# Get form data instead of JSON
|
||||
name = request.form.get('name')
|
||||
rule_text = request.form.get('rule_text')
|
||||
priority = request.form.get('priority')
|
||||
|
||||
if not name:
|
||||
# Return the folders list unchanged with an error message
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
# We'll add error handling in the template
|
||||
return render_template('partials/folders_list.html', folders=folders)
|
||||
|
||||
# Create new folder
|
||||
folder = Folder(
|
||||
user_id=MOCK_USER_ID,
|
||||
name=name,
|
||||
rule_text=rule_text,
|
||||
priority=int(priority) if priority else None
|
||||
)
|
||||
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
|
||||
# Get updated list of folders
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
|
||||
# Return the updated folders list HTML
|
||||
return render_template('partials/folders_list.html', folders=folders)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
# Return the folders list unchanged
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
return render_template('partials/folders_list.html', folders=folders)
|
||||
251
app/templates/index.html
Normal file
251
app/templates/index.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Organizer - Prototype</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#8B5CF6', /* Purple 500 */
|
||||
secondary: '#A78BFA', /* Purple 400 */
|
||||
accent: '#C4B5FD', /* Purple 300 */
|
||||
background: '#0F172A', /* Slate 900 */
|
||||
surface: '#1E293B', /* Slate 800 */
|
||||
text: '#F1F5F9', /* Slate 100 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0F172A; /* background */
|
||||
color: #F1F5F9; /* text */
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.sidebar {
|
||||
background-color: #1E293B; /* surface */
|
||||
border-right: 1px solid #334155; /* slate 700 */
|
||||
}
|
||||
.card {
|
||||
background-color: #1E293B; /* surface */
|
||||
border: 1px solid #334155; /* slate 700 */
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #8B5CF6; /* primary */
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #7C3AED; /* primary darker */
|
||||
}
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid #334155; /* slate 700 */
|
||||
color: #F1F5F9; /* text */
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background-color: #334155; /* slate 700 */
|
||||
}
|
||||
.btn-error {
|
||||
color: #F87171; /* red 400 */
|
||||
}
|
||||
.btn-error:hover {
|
||||
background-color: #7F1D1D; /* red 900 */
|
||||
}
|
||||
.input, .textarea {
|
||||
background-color: #0F172A; /* background */
|
||||
border: 1px solid #334155; /* slate 700 */
|
||||
border-radius: 0.375rem;
|
||||
color: #F1F5F9; /* text */
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.input:focus, .textarea:focus {
|
||||
border-color: #8B5CF6; /* primary */
|
||||
box-shadow: 0 0 0 1px #8B5CF6; /* primary */
|
||||
outline: none;
|
||||
}
|
||||
.nav-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background-color: #334155; /* slate 700 */
|
||||
}
|
||||
.nav-item.active {
|
||||
background-color: #8B5CF6; /* primary */
|
||||
color: white;
|
||||
}
|
||||
.user-dropdown {
|
||||
background-color: #1E293B; /* surface */
|
||||
border: 1px solid #334155; /* slate 700 */
|
||||
}
|
||||
.modal-box {
|
||||
background-color: #1E293B; /* surface */
|
||||
border: 1px solid #334155; /* slate 700 */
|
||||
}
|
||||
.notification-badge {
|
||||
background-color: #F87171; /* red 400 */
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar w-64 min-h-screen p-4 flex flex-col">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-primary flex items-center">
|
||||
<i class="fas fa-envelope mr-2"></i>
|
||||
Email Organizer
|
||||
</h1>
|
||||
<p class="text-secondary text-sm mt-1">AI-powered email organization</p>
|
||||
</div>
|
||||
|
||||
<nav class="flex-grow">
|
||||
<div class="nav-item active">
|
||||
<i class="fas fa-folder mr-3"></i>
|
||||
Folders
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-inbox mr-3"></i>
|
||||
Inbox
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-cog mr-3"></i>
|
||||
Settings
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-chart-bar mr-3"></i>
|
||||
Analytics
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-question-circle mr-3"></i>
|
||||
Help
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-4 border-t border-slate-700">
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-sign-out-alt mr-3"></i>
|
||||
Logout
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- Top Bar -->
|
||||
<header class="border-b border-slate-700 p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Folders</h2>
|
||||
<p class="text-secondary text-sm">Manage your email organization rules</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="relative p-2 rounded-full hover:bg-slate-700">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="notification-badge absolute top-0 right-0">3</span>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button id="user-menu-button" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-slate-700">
|
||||
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<span class="font-semibold">U</span>
|
||||
</div>
|
||||
<span>User Name</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div id="user-dropdown" class="user-dropdown absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 hidden">
|
||||
<a href="#" class="block px-4 py-2 text-sm hover:bg-slate-700">Profile</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm hover:bg-slate-700">Settings</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm hover:bg-slate-700">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 p-6 overflow-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Email Folders</h2>
|
||||
<p class="text-secondary">Create and manage your email organization rules</p>
|
||||
</div>
|
||||
<button class="btn btn-primary flex items-center" onclick="document.getElementById('add-folder-modal').showModal()">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section id="folders-list" class="mb-12">
|
||||
{% include 'partials/folders_list.html' %}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Add Folder Modal -->
|
||||
<dialog id="add-folder-modal" class="modal">
|
||||
<div class="modal-box card">
|
||||
<h3 class="font-bold text-lg mb-4">Add New Folder</h3>
|
||||
<form id="add-folder-form" hx-post="/api/folders" hx-target="#folders-list" hx-swap="innerHTML" hx-on:htmx:after-request="document.getElementById('add-folder-modal').close(); document.getElementById('add-folder-form').reset();">
|
||||
<div class="mb-4">
|
||||
<label for="folder-name" class="block text-sm font-medium mb-1">Name</label>
|
||||
<input type="text" id="folder-name" name="name" class="input w-full" placeholder="e.g., Work, Personal, Newsletters" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="folder-rule" class="block text-sm font-medium mb-1">Rule (Natural Language)</label>
|
||||
<textarea id="folder-rule" name="rule_text" class="textarea w-full h-24" placeholder="e.g., Move emails from 'newsletter@company.com' to this folder" required></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="folder-priority" class="block text-sm font-medium mb-1">Priority</label>
|
||||
<select id="folder-priority" name="priority" class="input w-full">
|
||||
<option value="1">High</option>
|
||||
<option value="0" selected>Normal</option>
|
||||
<option value="-1">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-outline" onclick="document.getElementById('add-folder-modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Folder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
// User dropdown toggle
|
||||
document.getElementById('user-menu-button').addEventListener('click', function() {
|
||||
const dropdown = document.getElementById('user-dropdown');
|
||||
dropdown.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const dropdown = document.getElementById('user-dropdown');
|
||||
const button = document.getElementById('user-menu-button');
|
||||
if (!button.contains(event.target) && !dropdown.contains(event.target)) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
34
app/templates/partials/folders_list.html
Normal file
34
app/templates/partials/folders_list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for folder in folders %}
|
||||
<div class="card rounded-lg p-6 shadow-lg">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-xl font-bold">{{ folder.name }}</h3>
|
||||
<div class="flex space-x-2">
|
||||
<button class="p-2 rounded-full hover:bg-slate-700">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="p-2 rounded-full hover:bg-slate-700 text-red-400">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary mb-4">{{ folder.rule_text }}</p>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-slate-700">Priority: {{ folder.priority or 'Normal' }}</span>
|
||||
<span class="text-xs text-secondary">0 emails</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-full text-center py-12">
|
||||
<div class="text-5xl mb-4 text-secondary">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">No folders yet</h3>
|
||||
<p class="text-secondary mb-4">Add your first folder to get started organizing your emails.</p>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('add-folder-modal').showModal()">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create Folder
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
10
config.py
Normal file
10
config.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/email_organizer_dev'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
config = {
|
||||
'default': Config
|
||||
}
|
||||
47
manage.py
Normal file
47
manage.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import sys
|
||||
from app import create_app, db
|
||||
from app.models import Folder, User
|
||||
from flask.cli import with_appcontext
|
||||
import click
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.cli.command()
|
||||
@with_appcontext
|
||||
def shell():
|
||||
"""Run a shell in the app context."""
|
||||
import code
|
||||
code.interact(local=dict(globals(), **locals()))
|
||||
|
||||
def mock_process_emails():
|
||||
"""Simulate processing emails with defined rules."""
|
||||
with app.app_context():
|
||||
# Mock user ID
|
||||
mock_user_id = '123e4567-e89b-12d3-a456-426614174000'
|
||||
folders = Folder.query.filter_by(user_id=mock_user_id).all()
|
||||
|
||||
# Mock emails
|
||||
emails = [
|
||||
{'subject': 'Your Amazon Order', 'from': 'no-reply@amazon.com', 'body': 'Your order has shipped.'},
|
||||
{'subject': 'Meeting Reminder', 'from': 'boss@company.com', 'body': 'Don\'t forget the meeting at 3 PM.'},
|
||||
{'subject': 'Special Offer!', 'from': 'deals@shop.com', 'body': 'Exclusive discounts inside!'}
|
||||
]
|
||||
|
||||
print("Starting mock email processing...")
|
||||
for email in emails:
|
||||
print(f"\nProcessing email: {email['subject']}")
|
||||
matched = False
|
||||
for folder in folders:
|
||||
# Simple mock rule matching (in real app, this would be more complex)
|
||||
if folder.rule_text.lower() in email['subject'].lower() or folder.rule_text.lower() in email['from'].lower():
|
||||
print(f" -> Matched rule '{folder.rule_text}' -> Folder '{folder.name}'")
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
print(" -> No matching rule found.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'mock-process':
|
||||
mock_process_emails()
|
||||
else:
|
||||
print("Usage: python manage.py mock-process")
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
BIN
migrations/__pycache__/env.cpython-310.pyc
Normal file
BIN
migrations/__pycache__/env.cpython-310.pyc
Normal file
Binary file not shown.
83
migrations/env.py
Normal file
83
migrations/env.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import User, Folder
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
target_metadata = db.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Use the Flask app's database configuration
|
||||
with app.app_context():
|
||||
connectable = db.engine
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
migrations/script.py.mako
Normal file
28
migrations/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Initial migration with users and folders tables
|
||||
|
||||
Revision ID: 1f23b4f802b0
|
||||
Revises:
|
||||
Create Date: 2025-08-03 08:30:06.300965
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1f23b4f802b0'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.LargeBinary(), nullable=True),
|
||||
sa.Column('imap_config', sa.JSON(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
op.create_table('folders',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('rule_text', sa.Text(), nullable=True),
|
||||
sa.Column('priority', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('folders')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
216
plans/milestone-1.md
Normal file
216
plans/milestone-1.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Milestone 1: Prototype Implementation Plan
|
||||
|
||||
## Objective
|
||||
Deliver a functional prototype demonstrating the core infrastructure, basic UI for rule configuration, a mock email processing pipeline, and database schema implementation as outlined in the project roadmap.
|
||||
|
||||
## Scope
|
||||
This milestone focuses on establishing the foundational elements required for the Email Organizer. It will not include actual email fetching, complex AI processing, or user authentication. The goal is to have a working skeleton that proves the core concepts.
|
||||
|
||||
## File and Folder Structure
|
||||
```
|
||||
email-organizer/
|
||||
├── app/ # Main application package
|
||||
│ ├── __init__.py # Flask app factory
|
||||
│ ├── models.py # SQLAlchemy models (User, Folder)
|
||||
│ ├── routes.py # Flask routes (UI endpoints)
|
||||
│ ├── static/ # Static files (CSS, JS, images)
|
||||
│ │ └── ... # HTMX, AlpineJS, Tailwind CSS files
|
||||
│ └── templates/ # Jinja2 HTML templates
|
||||
│ └── index.html # Main UI page
|
||||
├── migrations/ # Alembic migrations
|
||||
├── tests/ # Unit and integration tests
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest configuration and fixtures
|
||||
│ ├── test_models.py # Tests for database models
|
||||
│ └── test_routes.py # Tests for UI routes
|
||||
├── config.py # Application configuration
|
||||
├── manage.py # CLI commands (e.g., db setup, mock process)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env # Environment variables (not in VCS)
|
||||
├── .env.example # Example environment variables
|
||||
├── README.md
|
||||
└── plans/
|
||||
└── milestone-1.md # This plan
|
||||
```
|
||||
|
||||
## Sample Code
|
||||
|
||||
### 1. Flask App Factory (`app/__init__.py`)
|
||||
```python
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
from app.routes import main
|
||||
app.register_blueprint(main)
|
||||
|
||||
return app
|
||||
```
|
||||
|
||||
### 2. SQLAlchemy Models (`app/models.py`)
|
||||
```python
|
||||
from app import db
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False)
|
||||
# Placeholders for Milestone 1
|
||||
password_hash = db.Column(db.LargeBinary)
|
||||
imap_config = db.Column(db.JSONB)
|
||||
|
||||
class Folder(db.Model):
|
||||
__tablename__ = 'folders'
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
rule_text = db.Column(db.Text)
|
||||
priority = db.Column(db.Integer)
|
||||
|
||||
user = db.relationship('User', backref=db.backref('folders', lazy=True))
|
||||
```
|
||||
|
||||
### 3. Basic UI Route (`app/routes.py`)
|
||||
```python
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from app.models import Folder
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
@main.route('/')
|
||||
def index():
|
||||
# For prototype, use a mock user ID
|
||||
mock_user_id = '123e4567-e89b-12d3-a456-426614174000'
|
||||
folders = Folder.query.filter_by(user_id=mock_user_id).all()
|
||||
return render_template('index.html', folders=folders)
|
||||
|
||||
@main.route('/api/folders', methods=['POST'])
|
||||
def add_folder():
|
||||
# Mock implementation for prototype
|
||||
data = request.get_json()
|
||||
# In a real implementation, this would save to the database
|
||||
# For now, just echo back the data
|
||||
return jsonify({'message': 'Folder added (mock)', 'folder': data}), 201
|
||||
```
|
||||
|
||||
### 4. Simple HTML Template (`app/templates/index.html`)
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Email Organizer - Prototype</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Include AlpineJS if needed -->
|
||||
</head>
|
||||
<body class="bg-base-100">
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Email Organizer - Prototype</h1>
|
||||
|
||||
<div id="folders-list">
|
||||
<h2 class="text-xl font-semibold mb-2">Folders</h2>
|
||||
<ul>
|
||||
{% for folder in folders %}
|
||||
<li class="mb-2 p-2 bg-base-200 rounded">{{ folder.name }}: {{ folder.rule_text }}</li>
|
||||
{% endfor %
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="add-folder-form" class="mt-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Add Folder</h2>
|
||||
<form hx-post="/api/folders" hx-target="#folders-list" hx-swap="beforeend">
|
||||
<div class="mb-2">
|
||||
<label for="folder-name" class="block">Name:</label>
|
||||
<input type="text" id="folder-name" name="name" class="input input-bordered w-full max-w-xs" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="folder-rule" class="block">Rule (Natural Language):</label>
|
||||
<textarea id="folder-rule" name="rule_text" class="textarea textarea-bordered w-full" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Folder</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 5. Mock Email Processing Script (`manage.py`)
|
||||
```python
|
||||
import sys
|
||||
from app import create_app, db
|
||||
from app.models import Folder
|
||||
|
||||
def mock_process_emails():
|
||||
"""Simulate processing emails with defined rules."""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# Mock user ID
|
||||
mock_user_id = '123e4567-e89b-12d3-a456-426614174000'
|
||||
folders = Folder.query.filter_by(user_id=mock_user_id).all()
|
||||
|
||||
# Mock emails
|
||||
emails = [
|
||||
{'subject': 'Your Amazon Order', 'from': 'no-reply@amazon.com', 'body': 'Your order has shipped.'},
|
||||
{'subject': 'Meeting Reminder', 'from': 'boss@company.com', 'body': 'Don\'t forget the meeting at 3 PM.'},
|
||||
{'subject': 'Special Offer!', 'from': 'deals@shop.com', 'body': 'Exclusive discounts inside!'}
|
||||
]
|
||||
|
||||
print("Starting mock email processing...")
|
||||
for email in emails:
|
||||
print(f"\nProcessing email: {email['subject']}")
|
||||
matched = False
|
||||
for folder in folders:
|
||||
# Simple mock rule matching (in real app, this would be more complex)
|
||||
if folder.rule_text.lower() in email['subject'].lower() or folder.rule_text.lower() in email['from'].lower():
|
||||
print(f" -> Matched rule '{folder.rule_text}' -> Folder '{folder.name}'")
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
print(" -> No matching rule found.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'mock-process':
|
||||
mock_process_emails()
|
||||
else:
|
||||
print("Usage: python manage.py mock-process")
|
||||
```
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests (`tests/test_models.py`)
|
||||
- Test `User` model instantiation and basic properties.
|
||||
- Test `Folder` model instantiation, properties, and relationship with `User`.
|
||||
- Test database constraints (e.g., unique email for User).
|
||||
|
||||
### Integration Tests (`tests/test_routes.py`)
|
||||
- Test the `/` route loads successfully and renders the template.
|
||||
- Test the `/api/folders` POST endpoint accepts data and returns a JSON response (mock behavior).
|
||||
|
||||
### Setup (`tests/conftest.py`)
|
||||
- Use `pytest` fixtures to create an app instance and a temporary database for testing.
|
||||
- Provide a fixture to create a mock user and folders for tests.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. **Infrastructure**: The Flask application initializes correctly, connects to the PostgreSQL database, and the development server starts without errors.
|
||||
2. **Database Schema**: The `users` and `folders` tables are created in the database with the correct columns, data types, and relationships as defined in `models.py`.
|
||||
3. **UI Functionality**:
|
||||
* The root URL (`/`) loads the `index.html` template.
|
||||
* The page displays a list of folders (initially empty or seeded).
|
||||
* The "Add Folder" form is present and can be submitted.
|
||||
* Submitting the form sends a request to the `/api/folders` endpoint.
|
||||
4. **Mock Processing**:
|
||||
* The `python manage.py mock-process` command runs without errors.
|
||||
* The script correctly fetches folder rules from the database.
|
||||
* The script demonstrates matching mock emails to rules and prints the results to the console.
|
||||
5. **Code Quality**: Code follows Python best practices, is well-structured, and includes basic documentation/comments where necessary.
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Flask==2.3.2
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
psycopg2-binary==2.9.7
|
||||
pytest==7.4.0
|
||||
53
tests/conftest.py
Normal file
53
tests/conftest.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root directory to the Python path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import User, Folder
|
||||
import uuid
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
app = create_app('testing')
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # In-memory database for tests
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.drop_all()
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""A test client for the app."""
|
||||
return app.test_client()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user(app):
|
||||
"""Create a mock user for testing."""
|
||||
with app.app_context():
|
||||
user = User(
|
||||
id=uuid.UUID('123e4567-e89b-12d3-a456-426614174000'),
|
||||
email='test@example.com'
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def mock_folder(app, mock_user):
|
||||
"""Create a mock folder for testing."""
|
||||
with app.app_context():
|
||||
folder = Folder(
|
||||
user_id=mock_user.id,
|
||||
name='Test Folder',
|
||||
rule_text='Test rule',
|
||||
priority=1
|
||||
)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
return folder
|
||||
33
tests/test_models.py
Normal file
33
tests/test_models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from app.models import User, Folder
|
||||
import uuid
|
||||
|
||||
def test_user_model(app, mock_user):
|
||||
"""Test User model creation and properties."""
|
||||
with app.app_context():
|
||||
# Test user was created by fixture
|
||||
assert mock_user.id == uuid.UUID('123e4567-e89b-12d3-a456-426614174000')
|
||||
assert mock_user.email == 'test@example.com'
|
||||
|
||||
# Test querying user
|
||||
user_from_db = User.query.filter_by(email='test@example.com').first()
|
||||
assert user_from_db is not None
|
||||
assert user_from_db.id == mock_user.id
|
||||
|
||||
def test_folder_model(app, mock_folder, mock_user):
|
||||
"""Test Folder model creation and properties."""
|
||||
with app.app_context():
|
||||
# Test folder was created by fixture
|
||||
assert mock_folder.user_id == mock_user.id
|
||||
assert mock_folder.name == 'Test Folder'
|
||||
assert mock_folder.rule_text == 'Test rule'
|
||||
assert mock_folder.priority == 1
|
||||
|
||||
# Test relationship
|
||||
assert len(mock_user.folders) == 1
|
||||
assert mock_user.folders[0].id == mock_folder.id
|
||||
|
||||
# Test querying folder
|
||||
folder_from_db = Folder.query.filter_by(name='Test Folder').first()
|
||||
assert folder_from_db is not None
|
||||
assert folder_from_db.user_id == mock_user.id
|
||||
31
tests/test_routes.py
Normal file
31
tests/test_routes.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from app.models import User
|
||||
import uuid
|
||||
|
||||
def test_index_route(client, app):
|
||||
"""Test that the index route loads successfully."""
|
||||
with app.app_context():
|
||||
# Create a mock user for the test
|
||||
mock_user = User(
|
||||
id=uuid.UUID('123e4567-e89b-12d3-a456-426614174000'),
|
||||
email='test@example.com'
|
||||
)
|
||||
from app import db
|
||||
db.session.add(mock_user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
# Check if the page contains expected elements
|
||||
assert b'Email Organizer' in response.data
|
||||
assert b'Folders' in response.data
|
||||
|
||||
def test_add_folder_route(client):
|
||||
"""Test the add folder API endpoint."""
|
||||
response = client.post('/api/folders',
|
||||
json={'name': 'Test Folder', 'rule_text': 'Test rule'},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 201
|
||||
assert b'Folder added (mock)' in response.data
|
||||
assert b'Test Folder' in response.data
|
||||
Reference in New Issue
Block a user