From b0952aee5815cb685845c1dc0130671f42768793 Mon Sep 17 00:00:00 2001 From: Bryce Date: Sun, 3 Aug 2025 10:09:53 -0700 Subject: [PATCH] lots of progress on input processing --- .clj-kondo/.cache/v1/lock | 0 .env | 5 + .env.example | 2 + .lsp/.cache/db.transit.json | 1 + README.md | 113 +++++--- __pycache__/config.cpython-310.pyc | Bin 0 -> 563 bytes __pycache__/manage.cpython-310.pyc | Bin 0 -> 1720 bytes alembic.ini | 148 +++++++++++ app/__init__.py | 16 ++ app/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 571 bytes app/__pycache__/models.cpython-310.pyc | Bin 0 -> 1138 bytes app/__pycache__/routes.cpython-310.pyc | Bin 0 -> 1404 bytes app/models.py | 21 ++ app/routes.py | 58 ++++ app/templates/index.html | 251 ++++++++++++++++++ app/templates/partials/folders_list.html | 34 +++ config.py | 10 + manage.py | 47 ++++ migrations/README | 1 + migrations/__pycache__/env.cpython-310.pyc | Bin 0 -> 1973 bytes migrations/env.py | 83 ++++++ migrations/script.py.mako | 28 ++ ...802b0_initial_migration_with_users_and_.py | 49 ++++ ..._migration_with_users_and_.cpython-310.pyc | Bin 0 -> 1497 bytes plans/milestone-1.md | 216 +++++++++++++++ requirements.txt | 4 + tests/conftest.py | 53 ++++ tests/test_models.py | 33 +++ tests/test_routes.py | 31 +++ 29 files changed, 1172 insertions(+), 32 deletions(-) create mode 100644 .clj-kondo/.cache/v1/lock create mode 100644 .env create mode 100644 .env.example create mode 100644 .lsp/.cache/db.transit.json create mode 100644 __pycache__/config.cpython-310.pyc create mode 100644 __pycache__/manage.cpython-310.pyc create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-310.pyc create mode 100644 app/__pycache__/models.cpython-310.pyc create mode 100644 app/__pycache__/routes.cpython-310.pyc create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/templates/index.html create mode 100644 app/templates/partials/folders_list.html create mode 100644 config.py create mode 100644 manage.py create mode 100644 migrations/README create mode 100644 migrations/__pycache__/env.cpython-310.pyc create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/1f23b4f802b0_initial_migration_with_users_and_.py create mode 100644 migrations/versions/__pycache__/1f23b4f802b0_initial_migration_with_users_and_.cpython-310.pyc create mode 100644 plans/milestone-1.md create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_models.py create mode 100644 tests/test_routes.py 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 0000000000000000000000000000000000000000..3cd64df48256b4f0e343c1299826fe6eca2f39f6 GIT binary patch literal 563 zcmYjPJ#X7E5G5sBR^8YLkRe-!ZW>e-{aB15h@!};;V;y3hiD-LA7iVPMTe3d*h_|F z?_X%g{v|FQI`uDPNl#Xi`T!p8-Q9b4Bq$nP)r|^LnwC8j^~^ ynTgIM`*fe0bemN*;Fr-d$t_(L#WHD>)c?cEsEG~A{8--g7(d06U{k9Y=EFZ*(~(U8 literal 0 HcmV?d00001 diff --git a/__pycache__/manage.cpython-310.pyc b/__pycache__/manage.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..076ce1645cfe26922b421eb64c220c68ed78f2a2 GIT binary patch literal 1720 zcmZ8h-ESL35Z}Gqv(J|}aS}>MTfqiZ3YO|i^Fay~DnXR?DQVSEp;|(x^X)o5=RTa> zb5fsdsgnCz`UjMvMB;^iX&>nb#Z27<`}hch%z!pzv7=TGoNcW3~+(u zpFHd*)Tgq;qlktH%{rXIGj#fnE41-_@W6`Fz>n&dB03sy_3d#JxQ~WtuubI&SO7}8mTz+lQ8FEJ@ym-kh{HsvI<`+ za7*fH+Js^;56pi;E`dO+6e0f!Ooqaj|}Ic$c_YVOb+8Ci){b$y1$9rWYZBNO&>LzB&~pqJ>CQBHnn;BQmKOENOT?C7t-$)hE32@0X+$te@bS)&$TsMGsHMkiKOnYD z5J6*&*5w1VfuuNv6a9Ov@dUE^N4a|t#{CG8Lwh0(xRhaXNQ=x$ssTe8Z-)tIRP-Y* z-Td0d<`&=neCK9^fAM9jv9Zav8b0hB+nYN(8`~Q)G|ra`y;LjEjxPaDVXw!TTQ^l%_FG-9ancrPtVkftQ8*E&!Ow-XN{e-#ubw`=H^Jjo~)4mfL`pP=)b1_hrb{Ijkd948`Mv=050=iFCv=L8F z1KpgbdRioY%F-xE|C(uwVX+X6P@3iWR%9H~sF2Q|aXku!^4&=>^VJJniNSn~J z5Rw-8^2iOMQ0QTyEZ~A8Wx}G$fHQc46?5gicRG2Vr_6{aiumI2L|oQy==pVl6sfVl kg7+Z%w{KW)1SszqlaSMF0Q* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0bfec11de270003cc9facde25d39c57ab0a69f01 GIT binary patch literal 571 zcmYjPy>8nu5ayBkv!bMKw?2Y~T5E=&Xb>P%5g^T`g^)$sR!WKvMF&VAPwC!QaK^q8 z*G_$fPCd#%kpu42cL(0xcL$l%)106!_CGh@fRNuIcxw!UFX+QLf*=AE(NF^fP8HRx zW)o*B(or1&k?e)kvB*R$k{?hfBE2K!>0~B939C=@`{pNbcja?OaO{Ybh@WNMmS_6 z4cQS;3cInPIS!3<)Ai-dQVe~@dEaQsxecTI$;ukd&GKHh&Qr1US=V=tH`}ex5f;|$ zoE&Tt-hQxh-3?A!zEnH8wO#K@=1-0*OTXeEvw$RPE~Hr{FbSYSK-jd w-R2YL!-Hy$7q{W0)1r13Sg4(Jy@5IAe0HIY*eUrroNNexN1gh5XW!tJN7woK1C5CQMyRnq(mqJWC$OoNJ4SVY2#Vjo9x{=dv|~%Iw)u& zULh&-9K6N0RCxs&m^oV{2nyD^|IN;Zta8EqpgAL9F16u?qA-ES#yiohy%LaH? z=%>o)Z3_3&!$@?+;?~?=M0*KB>|_Cp7eX7aLM;xjnF)B2RF#Bl+cYOyrd&^xQ!zCG zh#{|3dGiyy>2+-5SD8GkgbA}sDSoU(ztc8hl4Lv;Nn#pFlIBn;vZEw9tGJp+T1isU z`Po+6n9ul6a#f|7Mh2E~sXn%Zx@&AS{ex1=Y-Bu5eyL>X~ zV&6g)JCms<{Dh&1P(sUP_t*xD>^7?-e-ZQX)E*uGg7V+Q<|L8LjNET-NZGxNAViwv zv;X8bXW2%U%gA>Hi2o`O`No9}O+3E_5i)KWzn3dO%E7RH7l)cU)gj7xSd-t8(CaYW zB6pL7Zcd}A`1usy!7(44YBB70*7YLAmq^+q%OnI*uaFQyy-Ko1vW{XJy<7`9%3jhO zQN2Wh-k^v}LtxxP@u4&enx>^BGuY2cF~W{R9Kl@6a%v(i6fb3-6=ONk59%*ds#~}- z>poT8LZQ19)ZvWVCR<|>em3no+6j$=tY_1=Rh%iV24j&`B!xlKAcVdBStOIA53ILEMzP8M)(V9p&DKQ literal 0 HcmV?d00001 diff --git a/app/__pycache__/routes.cpython-310.pyc b/app/__pycache__/routes.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..87df2461b8c4fbc372c7d8c6cc45fff506754a70 GIT binary patch literal 1404 zcmah|&2Jk;6rZpC@Or%tp#>Tw-~d8wQY3`>A>0bFm(?aSN!H!H|GKH*55pcSA@3Vj7v*^V;okmDaq!9Et)BSw zv!mYLKEK&3VBb5~KRnz!*t>PGySw`w4~}ibmk$BOcHs_u>LCb?M);Cwy2eYiKjOMnm=4_YB-hMo#- z;(HJ7e49TW-u*8B<}2yh<3I_e%4Te6!MX7vPF-s0+oRS=S!oDy1l~neNeV_tg4wvT z3!7(1-hkzKZ*bPsVlZxW$p>8gG;k^Q8hKib%ejz)Vlo-XW~zniPiDq*`L@ARB_LwT za13vRw@HlUyI`8f;8G5*DlzQggW)3pGpL0=Yqd=?DlVQck{!}c{dGNhB4!e|NeG2d}_bYJi1qaR?hmfp|a@M?XEpQ1DbCXb6+n3kMY z0_cCPO$Z~=$WqT_Y+JE2DWoooO16Ww{H|(U5 zwS2>xZI|Vi6>nLwZN=MGSW*}YW9aB4-?X-CR$R9St-I&PVxld-LfKT+QE~hv_5T;n zamn}_v<()HR2#8lFNuJe`R3GyRDBSH#6Xr9GqOz*LMXxZO|btW4uD8Bp$V~sdIyKU zkQee}a7nj}cTyGViL^uP65o_bfu5UTh(PPt4HuQ#RO}{*O{Xw-VV>J&=eZ26yX}DS r9H|{;U5HmQw}T;F`r+qrBLMUT29vVAxrSoAg+m+>Mngz5X(#w!c_&Uf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2fa9af4506e844b4aab0d16f4ae1489ad1114e7d GIT binary patch literal 1973 zcmZ`(&2Aev5MHiUD`_psa_l5_5~M_s!d1}9$fc*CXp=TYfWj!^Bt`eKpqI2nmCIF0 z%8t_nDBxat2=WM(kL@Gz+EZVlP2di>l7ITMi=7?L42R^$Z>H>Yk^r7NKfD?J(hP#% z-QfHKw%}9v^ji=#phyG-N)d8;D8iza*1TQ=JxU|5N1|TDX^a9Ej~i)&HO5U^pCa`> zLry=cX4ge4Z9$g0NYaG0#!Is{4OjyI&Rlkn5xk+#8H?qjm-dR4bj6Pw;!?4it`=+Q z8m!Yh3esh^JiB}xyjgggq-~9s+2>Qq-dt9&wrQG8~7+qp=$R*46R9R&C!WvD= zQh>Js7Q>#K?S5#JTrpx;MkW&*(p|7bPc>6Eepm|X{)(BYlDS)E@gPv#6+D1XKLcTc z8KMBotH6Zg+RMO1G^8~eaYXB{0e%zC>HtE=Yw03dE(00M0N26+fe zJ)TNj@S!4xmr`Rc@n*TVC*XLSxF{(bBngJ+$%t!Q9d)Xh#%9Fa(47+>a5KW=sWzD4 zr;oq#@kCNAORz}%g$L_Hm{LxMqcaPJj3m$5#E@OV{HzgE3=@qHm=FVu?;m3Q9iL3N z9Qp;TOxR-zyY*8J7^bD%G??u(i6vtccJcZB&%SwYS;&T?3ZT+H-5DtcNw)&e)-bb{E9kPd}44pHIu2CCwnla&*rxBCqn zSGe0{KZ|hbJaB$h2Xfff>8+@)8?Wp#AZKP2k(%T#&UV#QW@ne&N2&vnzXs|G{QkVV zH7X0X1-HfN7G?WetN_G5D%Frk{(`Bkvzc2=?hhu1_R7VC*_<)AA)Q_Wk+dTe`Zq@L z+oX}8TPJEA92bZlx=8pu(SHNQ5gmy9r6cmV_9}Q8%pw?XzCxxxj$b0vpy)O922-Ck z!QS>-YfkuZL3m&Ho9F2c<#6WhK{0&%e_k5yxZ*jDc4LU&J|FLF{o0G4|EqH3zY6D( z1mXbGbt`95stW*sQDst8a)JL()^J?oW&eW@BWv)+WuH0-*A8H$x+C~#%MmFXW8n3Yp z%9Zywvnn`K*IeS*2@g;LkPTex6I!`GtXfW72Vt9wnysL6jP}Q{%UmytAahgC;p7qX wU^VZ7Ybw|$uEXm)AlgAYY@>DK?FYKW>gb|>Xw37AaS^^mU4c)H?Pyhe` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d6c769671bda7dc64b6c69b2a6ca4c15497eec07 GIT binary patch literal 1497 zcmZ`(&2QsG6!%wb$8kQkl#g<<5Uhwq8`H9@60H!T)k*~v5ZzpI8BH=1XOKP4&P-a8 z!-@mf75@N-P1wPWX3To{z@)V{X@E;|ud0h&EW?0Ui=>{wC z98F3-Wtk3cxS|7dCSpW;92ZpA#UiIj(O;Af=Yo}jspTrH>K-vN=r)B}868h5+MtSQ z+L2iMXJx)9xb}v_i*xN?D#3WB8{cD*(Qg>XVyV4vFMk;5{T~H1B9`CLkD;APyr#WbSUdfP*{Eh!gu%V_v}5m zL%6-yerWWqr)7Nqh=-8&7Oh=yRlCAa$ + + + + 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