diff --git a/.clj-kondo/.cache/v1/lock b/.clj-kondo/.cache/v1/lock new file mode 100644 index 0000000..e69de29 diff --git a/.env b/.env new file mode 100644 index 0000000..619eb86 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf6224b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +SECRET_KEY=your-secret-key-here +DATABASE_URL=postgresql://localhost/email_organizer_dev \ No newline at end of file diff --git a/.lsp/.cache/db.transit.json b/.lsp/.cache/db.transit.json new file mode 100644 index 0000000..bca29f2 --- /dev/null +++ b/.lsp/.cache/db.transit.json @@ -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",[]]] \ No newline at end of file diff --git a/README.md b/README.md index feb3430..14c0198 100644 --- a/README.md +++ b/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. \ No newline at end of file diff --git a/__pycache__/config.cpython-310.pyc b/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..3cd64df Binary files /dev/null and b/__pycache__/config.cpython-310.pyc differ diff --git a/__pycache__/manage.cpython-310.pyc b/__pycache__/manage.cpython-310.pyc new file mode 100644 index 0000000..076ce16 Binary files /dev/null and b/__pycache__/manage.cpython-310.pyc differ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c3d0e94 --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a4e226a --- /dev/null +++ b/app/__init__.py @@ -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 \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-310.pyc b/app/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0bfec11 Binary files /dev/null and b/app/__pycache__/__init__.cpython-310.pyc differ diff --git a/app/__pycache__/models.cpython-310.pyc b/app/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000..aaab186 Binary files /dev/null and b/app/__pycache__/models.cpython-310.pyc differ diff --git a/app/__pycache__/routes.cpython-310.pyc b/app/__pycache__/routes.cpython-310.pyc new file mode 100644 index 0000000..87df246 Binary files /dev/null and b/app/__pycache__/routes.cpython-310.pyc differ diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..242a927 --- /dev/null +++ b/app/models.py @@ -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)) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..350ef34 --- /dev/null +++ b/app/routes.py @@ -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) \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..ce832b6 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,251 @@ + + + + + + Email Organizer - Prototype + + + + + + + + + + + + + +
+ +
+
+

Folders

+

Manage your email organization rules

+
+ +
+ + +
+ + + + +
+
+
+ + +
+
+
+

Email Folders

+

Create and manage your email organization rules

+
+ +
+ +
+ {% include 'partials/folders_list.html' %} +
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/app/templates/partials/folders_list.html b/app/templates/partials/folders_list.html new file mode 100644 index 0000000..f31a8c5 --- /dev/null +++ b/app/templates/partials/folders_list.html @@ -0,0 +1,34 @@ +
+ {% for folder in folders %} +
+
+

{{ folder.name }}

+
+ + +
+
+

{{ folder.rule_text }}

+
+ Priority: {{ folder.priority or 'Normal' }} + 0 emails +
+
+ {% else %} +
+
+ +
+

No folders yet

+

Add your first folder to get started organizing your emails.

+ +
+ {% endfor %} +
\ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..26a6d26 --- /dev/null +++ b/config.py @@ -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 +} \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..7218b23 --- /dev/null +++ b/manage.py @@ -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") \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/__pycache__/env.cpython-310.pyc b/migrations/__pycache__/env.cpython-310.pyc new file mode 100644 index 0000000..2fa9af4 Binary files /dev/null and b/migrations/__pycache__/env.cpython-310.pyc differ diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..a1dd73e --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/1f23b4f802b0_initial_migration_with_users_and_.py b/migrations/versions/1f23b4f802b0_initial_migration_with_users_and_.py new file mode 100644 index 0000000..403fc37 --- /dev/null +++ b/migrations/versions/1f23b4f802b0_initial_migration_with_users_and_.py @@ -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 ### diff --git a/migrations/versions/__pycache__/1f23b4f802b0_initial_migration_with_users_and_.cpython-310.pyc b/migrations/versions/__pycache__/1f23b4f802b0_initial_migration_with_users_and_.cpython-310.pyc new file mode 100644 index 0000000..d6c7696 Binary files /dev/null and b/migrations/versions/__pycache__/1f23b4f802b0_initial_migration_with_users_and_.cpython-310.pyc differ diff --git a/plans/milestone-1.md b/plans/milestone-1.md new file mode 100644 index 0000000..830dae6 --- /dev/null +++ b/plans/milestone-1.md @@ -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 + + + + + Email Organizer - Prototype + + + + + +
+

Email Organizer - Prototype

+ +
+

Folders

+
    + {% for folder in folders %} +
  • {{ folder.name }}: {{ folder.rule_text }}
  • + {% endfor % +
+
+ +
+

Add Folder

+
+
+ + +
+
+ + +
+ +
+
+
+ + +``` + +### 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. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..907c5d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==2.3.2 +Flask-SQLAlchemy==3.0.5 +psycopg2-binary==2.9.7 +pytest==7.4.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..633b7e1 --- /dev/null +++ b/tests/conftest.py @@ -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 \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e81e206 --- /dev/null +++ b/tests/test_models.py @@ -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 \ No newline at end of file diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..f320429 --- /dev/null +++ b/tests/test_routes.py @@ -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 \ No newline at end of file