# Messaging System Enhancements — Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add 6 incremental improvements to the `/wiadomosci` messaging system — flash messages, form hints, read receipts, recipient preview, branded email template, and file attachments. **Architecture:** Each feature is independent and deployed as a separate commit. Features 1-4 are pure template/route changes requiring no DB migrations. Feature 5 refactors email sending into the branded template service. Feature 6 introduces a new `MessageAttachment` model with file upload support. **Tech Stack:** Flask 3.0, SQLAlchemy 2.0, Jinja2, PostgreSQL, Microsoft Graph API (email), vanilla JS **Spec:** `docs/superpowers/specs/2026-03-11-messaging-enhancements-design.md` --- ## Chunk 1: Features 1-3 (Flash, Hint, Read Receipts) ### Task 1: Post-Send Flash Message **Files:** - Modify: `blueprints/messages/routes.py:226` (messages_send flash) - Modify: `blueprints/messages/routes.py:319` (messages_reply flash) - [ ] **Step 1: Update flash in messages_send()** Replace line 226 in `blueprints/messages/routes.py`: ```python # OLD: flash('Wiadomość wysłana.', 'success') # NEW: if recipient.notify_email_messages != False and recipient.email: flash('Wiadomość wysłana! Odbiorca zostanie powiadomiony emailem.', 'success') else: flash('Wiadomość wysłana!', 'success') ``` Note: The `recipient` variable is already available in scope (line 155). The email notification check (line 197) uses the same condition, so this is consistent. - [ ] **Step 2: Update flash in messages_reply()** Replace line 319 in `blueprints/messages/routes.py`. The reply route needs access to the recipient user object — `original` is already queried (line 289), and `recipient_id` is computed (line 298), but the actual `User` object isn't loaded. Add a query: ```python # After line 308 (after block_exists check), add: recipient = db.query(User).filter(User.id == recipient_id).first() # Then replace line 319: # OLD: flash('Odpowiedź wysłana.', 'success') # NEW: if recipient and recipient.notify_email_messages != False and recipient.email: flash('Odpowiedź wysłana! Odbiorca zostanie powiadomiony emailem.', 'success') else: flash('Odpowiedź wysłana!', 'success') ``` Note: `User` import is already at the top of the file (line 5). The recipient query is also needed later for Feature 5 (email notification in reply). - [ ] **Step 3: Test manually on dev** Run: `python3 app.py` 1. Send a message to a user with `notify_email_messages=True` — verify flash says "...powiadomiony emailem" 2. Send a message to a user with `notify_email_messages=False` — verify flash says only "Wiadomość wysłana!" 3. Reply to a message — verify same conditional behavior - [ ] **Step 4: Commit** ```bash git add blueprints/messages/routes.py git commit -m "feat(messages): add conditional post-send flash with email notification info" ``` --- ### Task 2: Contextual Hint on Compose Form **Files:** - Modify: `templates/messages/compose.html:357` (after form-actions div, before ``) - [ ] **Step 1: Add hint text to compose.html** In `templates/messages/compose.html`, add hint text after the `` on line 357 (closing `.form-actions`) and before `` on line 358: ```html
Temat: {subject}
' if subject else '' content_html = f'''{sender_name} wysłał(a) Ci wiadomość na portalu Norda Biznes.
{subject_html}|
{content_preview} |
| Przeczytaj wiadomość |
Możesz wyłączyć powiadomienia e-mail w ustawieniach prywatności.
''' html = _email_v3_wrap('Nowa wiadomość', f'od {sender_name}', content_html) text = f'{sender_name} wysłał(a) Ci wiadomość na portalu Norda Biznes. Odczytaj: {message_url}' return html, text ``` - [ ] **Step 2: Commit helper** ```bash git add email_service.py git commit -m "feat(email): add branded message notification email builder" ``` --- ### Task 7: Replace Inline HTML in messages_send() **Files:** - Modify: `blueprints/messages/routes.py:197-224` (email sending block in messages_send) - [ ] **Step 1: Import the new helper** At the top of `blueprints/messages/routes.py`, add to existing imports: ```python from email_service import send_email, build_message_notification_email ``` If `send_email` is already imported, just add `build_message_notification_email`. - [ ] **Step 2: Replace inline HTML email block** Replace lines 197-224 in `blueprints/messages/routes.py`: ```python # Send email notification if recipient has it enabled if recipient.notify_email_messages != False and recipient.email: try: message_url = url_for('.messages_view', message_id=message.id, _external=True) settings_url = url_for('auth.konto_prywatnosc', _external=True) sender_name = current_user.name or current_user.email.split('@')[0] preview = (content[:200] + '...') if len(content) > 200 else content subject_line = f'Nowa wiadomość od {sender_name} — Norda Biznes' email_html, email_text = build_message_notification_email( sender_name=sender_name, subject=subject, content_preview=preview, message_url=message_url, settings_url=settings_url ) send_email( to=[recipient.email], subject=subject_line, body_text=email_text, body_html=email_html, email_type='message_notification', user_id=recipient.id, recipient_name=recipient.name ) except Exception as e: import logging logging.getLogger(__name__).warning(f"Failed to send message email notification: {e}") ``` - [ ] **Step 3: Verify email on dev** Run `python3 app.py`, send a message. Check email (or logs) to verify the branded template is used — should show gradient header with Norda Biznes logo, styled content, CTA button, footer with address. - [ ] **Step 4: Commit** ```bash git add blueprints/messages/routes.py git commit -m "feat(messages): use branded email template for message notifications" ``` --- ### Task 8: Add Email Notification + UserNotification to messages_reply() **Files:** - Modify: `blueprints/messages/routes.py:309-317` (messages_reply after reply creation) The `messages_reply()` route currently does NOT send email notifications or create UserNotification. This is a gap — the reply sender's recipient doesn't know they got a reply unless they manually check the inbox. - [ ] **Step 1: Add imports if needed** Ensure these imports exist at the top of `blueprints/messages/routes.py`: ```python from database import UserNotification # may already be imported ``` - [ ] **Step 2: Add flush, notification, and email to messages_reply()** Replace lines 316-317 in `blueprints/messages/routes.py`: ```python # OLD: db.add(reply) db.commit() # NEW: db.add(reply) db.flush() # Get reply.id for notification link # Create in-app notification sender_name = current_user.name or current_user.email.split('@')[0] notification = UserNotification( user_id=recipient_id, title=f'Nowa odpowiedź od {sender_name}', message=f'{sender_name} odpowiedział(a) na wiadomość' + (f': {original.subject}' if original.subject else ''), notification_type='message', related_type='message', related_id=reply.id, action_url=url_for('.messages_view', message_id=message_id) ) db.add(notification) # Send email notification if recipient and recipient.notify_email_messages != False and recipient.email: try: message_url = url_for('.messages_view', message_id=message_id, _external=True) settings_url = url_for('auth.konto_prywatnosc', _external=True) preview = (content[:200] + '...') if len(content) > 200 else content subject_line = f'Nowa odpowiedź od {sender_name} — Norda Biznes' email_html, email_text = build_message_notification_email( sender_name=sender_name, subject=f"Re: {original.subject}" if original.subject else None, content_preview=preview, message_url=message_url, settings_url=settings_url ) send_email( to=[recipient.email], subject=subject_line, body_text=email_text, body_html=email_html, email_type='message_notification', user_id=recipient_id, recipient_name=recipient.name ) except Exception as e: import logging logging.getLogger(__name__).warning(f"Failed to send reply email notification: {e}") db.commit() ``` Note: The `recipient` variable was added in Task 1 Step 2 (queried after block_exists check). Make sure that step is done first. - [ ] **Step 3: Test reply notification on dev** 1. Send a message, then reply as the other user 2. Verify: in-app notification created (check bell icon), email sent with branded template 3. Reply to a message where recipient has `notify_email_messages=False` — verify no email sent - [ ] **Step 4: Commit** ```bash git add blueprints/messages/routes.py git commit -m "feat(messages): add email notification and in-app notification for replies" ``` --- ## Chunk 4: Feature 6 — File Attachments (Model + Migration) ### Task 9: Create MessageAttachment Model **Files:** - Modify: `database.py` (add new model after PrivateMessage class, line ~2287) - [ ] **Step 1: Add MessageAttachment model** After line 2287 in `database.py` (after the `PrivateMessage` class relationships), add: ```python class MessageAttachment(Base): """Załączniki do wiadomości prywatnych""" __tablename__ = 'message_attachments' id = Column(Integer, primary_key=True) message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE'), nullable=False) filename = Column(String(255), nullable=False) # original filename stored_filename = Column(String(255), nullable=False) # UUID-based on disk file_size = Column(Integer, nullable=False) # bytes mime_type = Column(String(100), nullable=False) created_at = Column(DateTime, default=datetime.now) message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan')) ``` Also add `backref` to the import from `sqlalchemy.orm` at the top of `database.py` if not already there. - [ ] **Step 2: Verify model loads** Run: `python3 -c "from database import MessageAttachment; print('OK')"` — should print "OK". - [ ] **Step 3: Commit** ```bash git add database.py git commit -m "feat(messages): add MessageAttachment model" ``` --- ### Task 10: Create SQL Migration **Files:** - Create: `database/migrations/063_message_attachments.sql` - [ ] **Step 1: Write migration SQL** ```sql -- Migration: 063_message_attachments.sql -- Description: Create message_attachments table for file attachments in messaging -- Date: 2026-03-11 CREATE TABLE IF NOT EXISTS message_attachments ( id SERIAL PRIMARY KEY, message_id INTEGER NOT NULL REFERENCES private_messages(id) ON DELETE CASCADE, filename VARCHAR(255) NOT NULL, stored_filename VARCHAR(255) NOT NULL, file_size INTEGER NOT NULL, mime_type VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_message_attachments_message_id ON message_attachments(message_id); -- Permissions GRANT ALL ON TABLE message_attachments TO nordabiz_app; GRANT USAGE, SELECT ON SEQUENCE message_attachments_id_seq TO nordabiz_app; ``` - [ ] **Step 2: Check migration number** ```bash ls database/migrations/ | tail -5 ``` Verify 063 doesn't conflict. If the last migration is 063 or higher, increment accordingly. - [ ] **Step 3: Run migration on dev** ```bash python3 scripts/run_migration.py database/migrations/063_message_attachments.sql ``` - [ ] **Step 4: Commit** ```bash git add database/migrations/063_message_attachments.sql git commit -m "feat(messages): add message_attachments migration" ``` --- ## Chunk 5: Feature 6 — File Attachments (Upload Service + Routes) ### Task 11: Create Message Upload Service **Files:** - Create: `message_upload_service.py` This service extends the `FileUploadService` pattern from `file_upload_service.py` but supports both images and documents. - [ ] **Step 1: Create message_upload_service.py** ```python """File upload service for message attachments.""" import os import uuid import struct from datetime import datetime from werkzeug.utils import secure_filename # Allowed file types and their limits IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} DOCUMENT_EXTENSIONS = {'pdf', 'docx', 'xlsx'} ALLOWED_EXTENSIONS = IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB per image MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB per document MAX_TOTAL_SIZE = 15 * 1024 * 1024 # 15MB total per message MAX_FILES_PER_MESSAGE = 3 # Magic bytes signatures for validation FILE_SIGNATURES = { 'jpg': [b'\xff\xd8\xff'], 'jpeg': [b'\xff\xd8\xff'], 'png': [b'\x89PNG\r\n\x1a\n'], 'gif': [b'GIF87a', b'GIF89a'], 'pdf': [b'%PDF'], 'docx': [b'PK\x03\x04'], # ZIP-based format 'xlsx': [b'PK\x03\x04'], # ZIP-based format } MIME_TYPES = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'pdf': 'application/pdf', 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } UPLOAD_BASE = 'static/uploads/messages' class MessageUploadService: """Handles file uploads for message attachments.""" def __init__(self, app_root: str): self.app_root = app_root def validate_files(self, files: list) -> tuple: """Validate list of uploaded files. Returns (valid_files, errors).""" errors = [] valid = [] if len(files) > MAX_FILES_PER_MESSAGE: errors.append(f'Maksymalnie {MAX_FILES_PER_MESSAGE} pliki na wiadomość.') return [], errors total_size = 0 for f in files: if not f or not f.filename: continue filename = secure_filename(f.filename) ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' if ext not in ALLOWED_EXTENSIONS: errors.append(f'Niedozwolony typ pliku: {filename}') continue # Read file content for size + magic bytes check content = f.read() f.seek(0) size = len(content) max_size = MAX_IMAGE_SIZE if ext in IMAGE_EXTENSIONS else MAX_DOCUMENT_SIZE if size > max_size: limit_mb = max_size // (1024 * 1024) errors.append(f'Plik {filename} przekracza limit {limit_mb}MB.') continue total_size += size # Magic bytes validation if ext in FILE_SIGNATURES: valid_sig = False for sig in FILE_SIGNATURES[ext]: if content[:len(sig)] == sig: valid_sig = True break if not valid_sig: errors.append(f'Plik {filename} ma nieprawidłowy format.') continue valid.append((f, filename, ext, size, content)) if total_size > MAX_TOTAL_SIZE: errors.append(f'Łączny rozmiar plików przekracza {MAX_TOTAL_SIZE // (1024*1024)}MB.') return [], errors return valid, errors def save_file(self, content: bytes, ext: str) -> tuple: """Save file to disk. Returns (stored_filename, relative_path).""" now = datetime.now() subdir = os.path.join(UPLOAD_BASE, str(now.year), f'{now.month:02d}') full_dir = os.path.join(self.app_root, subdir) os.makedirs(full_dir, exist_ok=True) stored_filename = f'{uuid.uuid4().hex}.{ext}' full_path = os.path.join(full_dir, stored_filename) with open(full_path, 'wb') as out: out.write(content) relative_path = os.path.join(subdir, stored_filename) return stored_filename, relative_path def get_mime_type(self, ext: str) -> str: """Get MIME type for extension.""" return MIME_TYPES.get(ext, 'application/octet-stream') def is_image(self, ext: str) -> bool: """Check if extension is an image type.""" return ext in IMAGE_EXTENSIONS def delete_file(self, relative_path: str): """Delete a file from disk.""" full_path = os.path.join(self.app_root, relative_path) if os.path.exists(full_path): os.remove(full_path) ``` - [ ] **Step 2: Verify service loads** Run: `python3 -c "from message_upload_service import MessageUploadService; print('OK')"` - [ ] **Step 3: Commit** ```bash git add message_upload_service.py git commit -m "feat(messages): add message upload service for file attachments" ``` --- ### Task 12: Add File Upload to messages_send() **Files:** - Modify: `blueprints/messages/routes.py` (messages_send route) - [ ] **Step 1: Import upload service and model** At the top of `blueprints/messages/routes.py`, add: ```python from message_upload_service import MessageUploadService from database import MessageAttachment ``` - [ ] **Step 2: Add file processing to messages_send()** After `db.flush()` (line 178, which gets `message.id`) and before the UserNotification creation (line ~180), add file processing: ```python # Process file attachments attachment_errors = [] if request.files.getlist('attachments'): import os upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) files = [f for f in request.files.getlist('attachments') if f and f.filename] if files: valid_files, attachment_errors = upload_service.validate_files(files) if attachment_errors: db.rollback() for err in attachment_errors: flash(err, 'error') return redirect(url_for('.messages_new')) for f, filename, ext, size, content in valid_files: stored_filename, relative_path = upload_service.save_file(content, ext) attachment = MessageAttachment( message_id=message.id, filename=filename, stored_filename=stored_filename, file_size=size, mime_type=upload_service.get_mime_type(ext) ) db.add(attachment) ``` - [ ] **Step 3: Similarly add to messages_reply()** In `messages_reply()`, after `db.flush()` (added in Task 8) and before the UserNotification creation, add the same file processing block but using `reply.id` instead of `message.id`: ```python # Process file attachments if request.files.getlist('attachments'): import os upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) files = [f for f in request.files.getlist('attachments') if f and f.filename] if files: valid_files, attachment_errors = upload_service.validate_files(files) if attachment_errors: db.rollback() for err in attachment_errors: flash(err, 'error') return redirect(url_for('.messages_view', message_id=message_id)) for f, filename, ext, size, content in valid_files: stored_filename, relative_path = upload_service.save_file(content, ext) attachment = MessageAttachment( message_id=reply.id, filename=filename, stored_filename=stored_filename, file_size=size, mime_type=upload_service.get_mime_type(ext) ) db.add(attachment) ``` - [ ] **Step 4: Commit** ```bash git add blueprints/messages/routes.py git commit -m "feat(messages): process file attachments on send and reply" ``` --- ## Chunk 6: Feature 6 — File Attachments (Templates) ### Task 13: Add File Input to Compose Form **Files:** - Modify: `templates/messages/compose.html` - [ ] **Step 1: Add enctype to form tag** Find the ` ``` - [ ] **Step 2: Commit** ```bash git add templates/messages/view.html git commit -m "feat(messages): add file upload to reply form" ``` --- ### Task 15: Display Attachments in Message View **Files:** - Modify: `templates/messages/view.html:226-227` (after message body) - Modify: `blueprints/messages/routes.py` (eager load attachments in messages_view) - [ ] **Step 1: Eager load attachments in messages_view()** In `messages_view()` route, update the message query to eager load attachments. Find the query line and add `options(joinedload(...))`: ```python from sqlalchemy.orm import joinedload # In messages_view(), update the message query: message = db.query(PrivateMessage).options( joinedload(PrivateMessage.attachments) ).filter( PrivateMessage.id == message_id ).first() ``` Note: Also check if `joinedload` is already imported. - [ ] **Step 2: Add attachment display in view.html** After line 226 (``) in `templates/messages/view.html`, add: ```html {% if message.attachments %} {% endif %} ``` - [ ] **Step 3: Test on dev** 1. Send a message with image attachment — verify inline thumbnail in view 2. Send a message with PDF — verify file icon with download link 3. Verify clicking image opens full size in new tab 4. Verify clicking document downloads with original filename - [ ] **Step 4: Commit** ```bash git add templates/messages/view.html blueprints/messages/routes.py git commit -m "feat(messages): display attachments in message view" ``` --- ### Task 16: Add Attachment Indicator to Inbox and Sent Box **Files:** - Modify: `templates/messages/inbox.html` (paperclip icon) - Modify: `templates/messages/sent.html` (paperclip icon) - Modify: `blueprints/messages/routes.py` (eager load in inbox/sent queries) - [ ] **Step 1: Eager load attachments in messages_inbox()** In `messages_inbox()` route, add `options(joinedload(PrivateMessage.attachments))` to the query. - [ ] **Step 2: Eager load attachments in messages_sent()** In `messages_sent()` route, add `options(joinedload(PrivateMessage.attachments))` to the query. - [ ] **Step 3: Add paperclip icon to inbox.html** In `templates/messages/inbox.html`, find the message-preview div and add after it: ```html {% if msg.attachments %} 📎 {{ msg.attachments|length }} {% endif %} ``` - [ ] **Step 4: Add paperclip icon to sent.html** Same pattern in `templates/messages/sent.html` — add after the message-preview div. - [ ] **Step 5: Commit** ```bash git add templates/messages/inbox.html templates/messages/sent.html blueprints/messages/routes.py git commit -m "feat(messages): add attachment indicator in inbox and sent box" ``` --- ## Chunk 7: Final Testing & Deployment ### Task 17: Full Integration Test on Dev - [ ] **Step 1: Start dev server** ```bash python3 app.py ``` - [ ] **Step 2: Test all 6 features end-to-end** 1. **Flash message:** Send message → verify conditional flash text 2. **Hint:** Compose form → verify email hint below button 3. **Read receipts:** Sent box → verify checkmark icons and tooltips 4. **Recipient preview:** Select recipient → verify mini business card 5. **Branded email:** Send message → verify branded email received 6. **Attachments:** Send with image + PDF → verify upload, display, download - [ ] **Step 3: Test edge cases** 1. Send message without attachments (backward compat) 2. Send with 4+ files → verify error 3. Send with oversized file → verify error 4. Reply with attachment → verify works 5. Mobile viewport → verify file upload zone works ### Task 18: Deploy to Staging - [ ] **Step 1: Push to remotes** ```bash git push origin master && git push inpi master ``` - [ ] **Step 2: Deploy to staging** ```bash ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes" ``` - [ ] **Step 3: Run migration on staging** ```bash ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/063_message_attachments.sql" ``` - [ ] **Step 4: Verify on staging** ```bash curl -sI https://staging.nordabiznes.pl/health | head -3 ``` Test messaging features manually on staging. ### Task 19: Deploy to Production - [ ] **Step 1: Deploy** ```bash ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull" ``` - [ ] **Step 2: Run migration** ```bash ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/063_message_attachments.sql" ``` - [ ] **Step 3: Create upload directory** ```bash ssh maciejpi@57.128.200.27 "mkdir -p /var/www/nordabiznes/static/uploads/messages && sudo chown -R maciejpi:maciejpi /var/www/nordabiznes/static/uploads/messages" ``` - [ ] **Step 4: Restart and verify** ```bash ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes" curl -sI https://nordabiznes.pl/health | head -3 ``` - [ ] **Step 5: Update release notes** Add new entries to `release_notes` in `blueprints/public/routes.py` following the existing style (user-facing language, no technical jargon).