lots of progress on input processing

This commit is contained in:
Bryce
2025-08-03 10:09:53 -07:00
parent 97545d89d2
commit b0952aee58
29 changed files with 1172 additions and 32 deletions

View File

5
.env Normal file
View 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
View File

@@ -0,0 +1,2 @@
SECRET_KEY=your-secret-key-here
DATABASE_URL=postgresql://localhost/email_organizer_dev

View 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
View File

@@ -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.

Binary file not shown.

Binary file not shown.

148
alembic.ini Normal file
View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
app/models.py Normal file
View 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
View 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
View 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>

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Generic single-database configuration.

Binary file not shown.

83
migrations/env.py Normal file
View 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
View 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"}

View File

@@ -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 ###

216
plans/milestone-1.md Normal file
View 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
View 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
View 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
View 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
View 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