19 tasks across 7 chunks covering 6 features: flash messages, form hints, read receipts, recipient preview, branded email, file attachments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
44 KiB
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:
# 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:
# 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
- Send a message to a user with
notify_email_messages=True— verify flash says "...powiadomiony emailem" - Send a message to a user with
notify_email_messages=False— verify flash says only "Wiadomość wysłana!" - Reply to a message — verify same conditional behavior
- Step 4: Commit
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</form>) -
Step 1: Add hint text to compose.html
In templates/messages/compose.html, add hint text after the </div> on line 357 (closing .form-actions) and before </form> on line 358:
</div>
<div class="form-hint" style="text-align: center; margin-top: 12px; padding: 8px 16px; font-size: var(--font-size-sm); color: var(--text-secondary);">
📧 Odbiorca zostanie powiadomiony o nowej wiadomości emailem
</div>
</form>
- Step 2: Verify visually on dev
Run: python3 app.py
Navigate to /wiadomosci/nowa — verify the hint text appears below the submit button, styled as muted secondary text.
- Step 3: Commit
git add templates/messages/compose.html
git commit -m "feat(messages): add email notification hint on compose form"
Task 3: Read Receipts in Sent Box
Files:
-
Modify:
templates/messages/sent.html:128-131(CSS) andtemplates/messages/sent.html:211-213(indicator) -
Step 1: Update CSS for read receipts
In templates/messages/sent.html, replace the .read-status CSS block (lines 128-131):
/* OLD: */
.read-status {
font-size: var(--font-size-xs);
color: var(--success);
}
/* NEW: */
.read-status {
font-size: var(--font-size-xs);
display: inline-flex;
align-items: center;
gap: 4px;
}
.read-status.is-read {
color: var(--primary);
}
.read-status.is-unread {
color: var(--text-secondary);
}
.read-status svg {
width: 14px;
height: 14px;
}
- Step 2: Update read indicator in message list
Replace lines 211-213 in templates/messages/sent.html:
<!-- OLD: -->
{% if msg.is_read %}
<div class="read-status">Przeczytana</div>
{% endif %}
<!-- NEW: -->
{% if msg.is_read %}
<div class="read-status is-read" title="Przeczytana {{ msg.read_at.strftime('%d.%m.%Y %H:%M') if msg.read_at else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/><path d="M22 7l-8 8-1.5-1.5" opacity="0.6"/></svg>
Przeczytana
</div>
{% else %}
<div class="read-status is-unread">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/></svg>
Wysłana
</div>
{% endif %}
The double checkmark SVG (two overlapping check paths) indicates read, single checkmark indicates sent. The title attribute on read messages shows the read_at timestamp on hover. No new DB queries needed — is_read and read_at are already on the PrivateMessage model (database.py:2273-2274).
- Step 3: Verify on dev
Run: python3 app.py
- Navigate to
/wiadomosci/wyslane— verify all sent messages show either single gray checkmark "Wysłana" or double blue checkmark "Przeczytana" - Hover over read messages — verify
read_attimestamp appears as tooltip
- Step 4: Commit
git add templates/messages/sent.html
git commit -m "feat(messages): add read receipt indicators in sent box"
Chunk 2: Feature 4 (Recipient Profile Preview)
Task 4: Extend User Query with Company Data
Files:
-
Modify:
blueprints/messages/routes.py:96-100(messages_new user query) -
Step 1: Add LEFT JOIN to Company
Replace lines 96-100 in blueprints/messages/routes.py. The current query:
users = db.query(User).filter(
User.is_active == True,
User.is_verified == True,
User.id != current_user.id
).order_by(User.name).all()
Replace with:
from sqlalchemy import func
from database import UserCompanyPermissions, Company
# Query users with their primary company info
users_with_companies = db.query(
User,
Company.name.label('company_name'),
Company.slug.label('company_slug'),
UserCompanyPermissions.position.label('position')
).outerjoin(
UserCompanyPermissions,
(UserCompanyPermissions.user_id == User.id)
).outerjoin(
Company,
(Company.id == UserCompanyPermissions.company_id) & (Company.status == 'active')
).filter(
User.is_active == True,
User.is_verified == True,
User.id != current_user.id
).order_by(User.name).all()
# Deduplicate users (one user may have multiple company permissions)
seen_ids = set()
users = []
for user, company_name, company_slug, position in users_with_companies:
if user.id not in seen_ids:
seen_ids.add(user.id)
user._company_name = company_name
user._company_slug = company_slug
user._position = position
users.append(user)
Note: UserCompanyPermissions and Company may need importing at the top of the file. Check existing imports first — Company may already be imported. The func import from sqlalchemy is for safety but not strictly needed here.
- Step 2: Verify query returns data on dev
Run python3 app.py, navigate to /wiadomosci/nowa, open browser dev tools → check the users JSON in page source to confirm company data fields will be present (after Step 3).
- Step 3: Commit query change
git add blueprints/messages/routes.py
git commit -m "feat(messages): extend user query with company data for recipient preview"
Task 5: Add Preview Card to Compose Template
Files:
-
Modify:
templates/messages/compose.html(user JSON, preview card HTML, JS) -
Step 1: Extend user JSON in template
In templates/messages/compose.html, update the users JS array (lines 366-369). Change from:
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, showEmail: {{ 'true' if user.privacy_show_email != False else 'false' }}}{{ ',' if not loop.last }}
To:
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, showEmail: {{ 'true' if user.privacy_show_email != False else 'false' }}, companyName: {{ (user._company_name or '') | tojson }}, companySlug: {{ (user._company_slug or '') | tojson }}, position: {{ (user._position or '') | tojson }}}{{ ',' if not loop.last }}
- Step 2: Add preview card HTML
In templates/messages/compose.html, add preview card HTML after the #recipient-selected div. Find the recipient-selected div (around line 330-335) and add below it:
<div id="recipient-preview" style="display: none; margin-top: 8px; padding: 12px 16px; background: var(--bg-secondary); border-radius: var(--radius); border: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; gap: 12px;">
<div id="preview-avatar" style="width: 40px; height: 40px; border-radius: 50%; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 16px;"></div>
<div>
<div id="preview-name" style="font-weight: 600; color: var(--text-primary);"></div>
<div id="preview-company" style="font-size: var(--font-size-sm); color: var(--text-secondary);"></div>
</div>
</div>
</div>
- Step 3: Update selectRecipient() JS function
In the selectRecipient function (line 408), add preview card population. After searchInput.value = ''; (line 416), add:
// Show recipient preview card
var user = users.find(function(u) { return u.id === id; });
var previewDiv = document.getElementById('recipient-preview');
if (user && (user.companyName)) {
document.getElementById('preview-avatar').textContent = (name || email)[0].toUpperCase();
document.getElementById('preview-name').textContent = name;
var companyHtml = '';
if (user.companyName) {
companyHtml = user.companySlug
? '<a href="/firma/' + user.companySlug + '" target="_blank" style="color: var(--primary); text-decoration: none;">' + user.companyName + '</a>'
: user.companyName;
}
if (user.position) {
companyHtml = user.position + (companyHtml ? ' · ' + companyHtml : '');
}
document.getElementById('preview-company').innerHTML = companyHtml;
previewDiv.style.display = 'block';
} else {
previewDiv.style.display = 'none';
}
- Step 4: Update clearRecipient() to hide preview
In clearRecipient() (line 419), add after searchInput.focus(); (line 424):
document.getElementById('recipient-preview').style.display = 'none';
- Step 5: Verify on dev
Run python3 app.py, navigate to /wiadomosci/nowa:
- Type a user name in the autocomplete
- Select a user — verify preview card appears with avatar, name, and company
- Click "x" to clear — verify preview card disappears
- Select a user without company — verify no preview card shown
- Step 6: Commit
git add templates/messages/compose.html
git commit -m "feat(messages): add recipient profile preview card on compose form"
Chunk 3: Feature 5 (Branded Email Template)
Task 6: Create Branded Email Helper
Files:
-
Modify:
email_service.py(new helper function after_email_v3_wrap) -
Step 1: Add helper function to email_service.py
After the _email_v3_wrap() function (after line 381), add:
def build_message_notification_email(sender_name: str, subject: str, content_preview: str, message_url: str, settings_url: str) -> tuple:
"""Build branded email for message notification. Returns (html, text)."""
subject_html = f'<p style="margin:0 0 16px; color:#475569; font-size:15px;"><strong>Temat:</strong> {subject}</p>' if subject else ''
content_html = f'''
<p style="margin:0 0 20px; color:#1e293b; font-size:16px;">
<strong>{sender_name}</strong> wysłał(a) Ci wiadomość na portalu Norda Biznes.
</p>
{subject_html}
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-left:4px solid #3b82f6; border-radius:4px; margin-bottom:24px;">
<tr><td style="padding:16px;">
<p style="margin:0; color:#374151; font-size:14px;">{content_preview}</p>
</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr><td align="center" style="padding:8px 0;">
<a href="{message_url}" style="display:inline-block; padding:14px 36px; background-color:#3b82f6; color:#ffffff; text-decoration:none; border-radius:8px; font-size:15px; font-weight:600;">Przeczytaj wiadomość</a>
</td></tr>
</table>
<p style="margin:0; color:#94a3b8; font-size:12px; text-align:center;">
Możesz wyłączyć powiadomienia e-mail w <a href="{settings_url}" style="color:#2563eb;">ustawieniach prywatności</a>.
</p>
'''
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
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:
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:
# 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
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:
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:
# 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
- Send a message, then reply as the other user
- Verify: in-app notification created (check bell icon), email sent with branded template
- Reply to a message where recipient has
notify_email_messages=False— verify no email sent
- Step 4: Commit
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:
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
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
-- 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
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
python3 scripts/run_migration.py database/migrations/063_message_attachments.sql
- Step 4: Commit
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
"""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
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:
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:
# 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:
# 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
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 <form tag in compose.html and add enctype="multipart/form-data". It should look like:
<form method="POST" action="..." enctype="multipart/form-data">
- Step 2: Add file input before form-actions
Before the .form-actions div (line 348), add:
<div class="form-group">
<label>Załączniki (maks. 3 pliki, 15MB łącznie)</label>
<div class="file-upload-zone" id="file-drop-zone" style="border: 2px dashed var(--border-color); border-radius: var(--radius); padding: 20px; text-align: center; cursor: pointer; transition: border-color 0.2s;">
<input type="file" name="attachments" id="file-input" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="display: none;">
<p style="margin: 0; color: var(--text-secondary); font-size: var(--font-size-sm);">
Przeciągnij pliki tutaj lub <a href="#" onclick="document.getElementById('file-input').click(); return false;" style="color: var(--primary);">wybierz z dysku</a>
</p>
<p style="margin: 4px 0 0; color: var(--text-secondary); font-size: var(--font-size-xs);">
JPG, PNG, GIF (5MB) · PDF, DOCX, XLSX (10MB)
</p>
</div>
<div id="file-list" style="margin-top: 8px;"></div>
</div>
- Step 3: Add file handling JS
In the {% block extra_js %} section, add file handling JavaScript (at the end of the existing IIFE or as a new block):
// File attachment handling
(function() {
var dropZone = document.getElementById('file-drop-zone');
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
if (!dropZone) return;
dropZone.addEventListener('click', function(e) {
if (e.target.tagName !== 'A') fileInput.click();
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.style.borderColor = 'var(--primary)';
});
dropZone.addEventListener('dragleave', function() {
dropZone.style.borderColor = 'var(--border-color)';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.style.borderColor = 'var(--border-color)';
var dt = new DataTransfer();
Array.from(e.dataTransfer.files).forEach(function(f) { dt.items.add(f); });
Array.from(fileInput.files).forEach(function(f) { dt.items.add(f); });
fileInput.files = dt.files;
updateFileList();
});
fileInput.addEventListener('change', updateFileList);
function updateFileList() {
var files = Array.from(fileInput.files);
if (files.length === 0) {
fileList.innerHTML = '';
return;
}
fileList.innerHTML = files.map(function(f, i) {
var sizeMB = (f.size / 1024 / 1024).toFixed(1);
return '<div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: var(--font-size-sm);">' +
'<span style="color: var(--text-secondary);">📎</span> ' +
'<span>' + f.name + '</span> ' +
'<span style="color: var(--text-secondary);">(' + sizeMB + ' MB)</span> ' +
'<a href="#" onclick="removeFile(' + i + '); return false;" style="color: var(--danger); margin-left: auto;">✕</a>' +
'</div>';
}).join('');
}
window.removeFile = function(index) {
var dt = new DataTransfer();
Array.from(fileInput.files).forEach(function(f, i) {
if (i !== index) dt.items.add(f);
});
fileInput.files = dt.files;
updateFileList();
};
})();
- Step 4: Commit
git add templates/messages/compose.html
git commit -m "feat(messages): add file upload input to compose form"
Task 14: Add File Input to Reply Form
Files:
-
Modify:
templates/messages/view.html:233-239(reply form) -
Step 1: Add enctype and file input to reply form
Replace lines 233-239 in templates/messages/view.html:
<form method="POST" action="{{ url_for('.messages_reply', message_id=message.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<textarea name="content" rows="4" required placeholder="Napisz odpowiedz..."></textarea>
</div>
<div class="form-group" style="margin-top: 8px;">
<input type="file" name="attachments" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="font-size: var(--font-size-sm);">
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Maks. 3 pliki, 15MB łącznie</span>
</div>
<button type="submit" class="btn btn-primary">Wyslij odpowiedz</button>
</form>
- Step 2: Commit
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(...)):
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 (<div class="message-body">{{ message.content }}</div>) in templates/messages/view.html, add:
{% if message.attachments %}
<div class="message-attachments" style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<h4 style="font-size: var(--font-size-sm); color: var(--text-secondary); margin: 0 0 8px;">Załączniki ({{ message.attachments|length }})</h4>
{% for att in message.attachments %}
{% set is_image = att.mime_type.startswith('image/') %}
<div style="display: flex; align-items: center; gap: 10px; padding: 8px 0; {% if not loop.last %}border-bottom: 1px solid var(--border-color);{% endif %}">
{% if is_image %}
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" target="_blank">
<img src="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" alt="{{ att.filename }}" style="max-width: 300px; max-height: 200px; border-radius: var(--radius-sm); border: 1px solid var(--border-color);">
</a>
{% else %}
<span style="font-size: 20px;">📄</span>
<div>
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" download="{{ att.filename }}" style="color: var(--primary); font-weight: 500;">{{ att.filename }}</a>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-left: 8px;">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
- Step 3: Test on dev
- Send a message with image attachment — verify inline thumbnail in view
- Send a message with PDF — verify file icon with download link
- Verify clicking image opens full size in new tab
- Verify clicking document downloads with original filename
- Step 4: Commit
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:
{% if msg.attachments %}
<span style="color: var(--text-secondary); font-size: var(--font-size-xs);" title="{{ msg.attachments|length }} załącznik{{ 'ów' if msg.attachments|length > 1 else '' }}">📎 {{ msg.attachments|length }}</span>
{% 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
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
python3 app.py
- Step 2: Test all 6 features end-to-end
- Flash message: Send message → verify conditional flash text
- Hint: Compose form → verify email hint below button
- Read receipts: Sent box → verify checkmark icons and tooltips
- Recipient preview: Select recipient → verify mini business card
- Branded email: Send message → verify branded email received
- Attachments: Send with image + PDF → verify upload, display, download
- Step 3: Test edge cases
- Send message without attachments (backward compat)
- Send with 4+ files → verify error
- Send with oversized file → verify error
- Reply with attachment → verify works
- Mobile viewport → verify file upload zone works
Task 18: Deploy to Staging
- Step 1: Push to remotes
git push origin master && git push inpi master
- Step 2: Deploy to staging
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
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
curl -sI https://staging.nordabiznes.pl/health | head -3
Test messaging features manually on staging.
Task 19: Deploy to Production
- Step 1: Deploy
ssh maciejpi@10.22.68.249 "cd /var/www/nordabiznes && sudo -u www-data git pull"
- Step 2: Run migration
ssh maciejpi@10.22.68.249 "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
ssh maciejpi@10.22.68.249 "mkdir -p /var/www/nordabiznes/static/uploads/messages && sudo chown -R maciejpi:maciejpi /var/www/nordabiznes/static/uploads/messages"
- Step 4: Restart and verify
ssh maciejpi@10.22.68.249 "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).