nordabiz/docs/superpowers/plans/2026-03-11-messaging-enhancements.md
Maciej Pienczyn e3980f046f docs: add messaging enhancements implementation plan
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>
2026-03-11 17:18:39 +01:00

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

  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
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) 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):

/* 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

  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
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:

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