nordabiz/docs/superpowers/plans/2026-03-11-messaging-enhancements.md
Maciej Pienczyn 110d971dca
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

1219 lines
44 KiB
Markdown

# 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 `</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:
```html
</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**
```bash
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) and `templates/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):
```css
/* 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`:
```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`
1. Navigate to `/wiadomosci/wyslane` — verify all sent messages show either single gray checkmark "Wysłana" or double blue checkmark "Przeczytana"
2. Hover over read messages — verify `read_at` timestamp appears as tooltip
- [ ] **Step 4: Commit**
```bash
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:
```python
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:
```python
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**
```bash
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:
```javascript
{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:
```javascript
{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:
```html
<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:
```javascript
// 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):
```javascript
document.getElementById('recipient-preview').style.display = 'none';
```
- [ ] **Step 5: Verify on dev**
Run `python3 app.py`, navigate to `/wiadomosci/nowa`:
1. Type a user name in the autocomplete
2. Select a user — verify preview card appears with avatar, name, and company
3. Click "x" to clear — verify preview card disappears
4. Select a user without company — verify no preview card shown
- [ ] **Step 6: Commit**
```bash
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:
```python
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**
```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 `<form` tag in `compose.html` and add `enctype="multipart/form-data"`. It should look like:
```html
<form method="POST" action="..." enctype="multipart/form-data">
```
- [ ] **Step 2: Add file input before form-actions**
Before the `.form-actions` div (line 348), add:
```html
<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):
```javascript
// 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**
```bash
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`:
```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**
```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 (`<div class="message-body">{{ message.content }}</div>`) in `templates/messages/view.html`, add:
```html
{% 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**
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 %}
<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**
```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).