vboxes fix

This commit is contained in:
2026-03-19 14:28:09 +01:00
parent b7f7c1f7c0
commit 06dfbfcbc4
30 changed files with 4405 additions and 166 deletions
+3
View File
@@ -135,6 +135,9 @@ venv.bak/
# Rope project settings
.ropeproject
# NodeModules
node_modules/
# mkdocs documentation
/site
+237
View File
@@ -0,0 +1,237 @@
"""
Router tag (Label).
Endpoint:
- GET /labels elenca i tag del tenant
- POST /labels crea un nuovo tag (admin)
- PATCH /labels/{id} modifica un tag (admin)
- DELETE /labels/{id} elimina un tag (admin)
- GET /messages/{id}/labels tag di un messaggio
- PUT /messages/{id}/labels imposta i tag di un messaggio (sostituisce)
- POST /messages/{id}/labels/add aggiunge tag a un messaggio
- POST /messages/{id}/labels/remove rimuove tag da un messaggio
- POST /messages/bulk-labels aggiunge/rimuove tag in blocco
Permessi:
- GET /labels: tutti gli utenti autenticati
- POST/PATCH/DELETE: solo admin
- Operazioni su messaggi: utenti con accesso alla casella del messaggio
"""
import uuid
from fastapi import APIRouter, status
from sqlalchemy import select
from app.core.exceptions import ForbiddenError, NotFoundError
from app.dependencies import AdminUser, CurrentUser, DB
from app.models.message import Message
from app.schemas.label import (
LabelCreate,
LabelResponse,
LabelUpdate,
MessageBulkLabelRequest,
MessageBulkLabelResponse,
MessageLabelAddRequest,
MessageLabelRemoveRequest,
MessageLabelSetRequest,
)
from app.services.label_service import LabelService
router = APIRouter(tags=["Labels"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
async def _check_message_access(
message_id: uuid.UUID,
current_user,
db,
) -> Message:
"""Verifica che il messaggio esista e l'utente vi abbia accesso."""
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
)
message = result.scalar_one_or_none()
if not message:
raise NotFoundError(f"Messaggio {message_id} non trovato")
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, message.mailbox_id):
raise ForbiddenError("Accesso al messaggio non autorizzato")
return message
# ─── CRUD Label ───────────────────────────────────────────────────────────────
@router.get("/labels", response_model=list[LabelResponse])
async def list_labels(
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Elenca tutti i tag del tenant corrente."""
svc = LabelService(db)
labels = await svc.list_labels(current_user.tenant_id)
return [LabelResponse.model_validate(l) for l in labels]
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
async def create_label(
data: LabelCreate,
current_user: AdminUser,
db: DB,
) -> LabelResponse:
"""Crea un nuovo tag (solo admin)."""
svc = LabelService(db)
label = await svc.create_label(current_user.tenant_id, data)
return LabelResponse.model_validate(label)
@router.patch("/labels/{label_id}", response_model=LabelResponse)
async def update_label(
label_id: uuid.UUID,
data: LabelUpdate,
current_user: AdminUser,
db: DB,
) -> LabelResponse:
"""Modifica un tag esistente (solo admin)."""
svc = LabelService(db)
label = await svc.update_label(current_user.tenant_id, label_id, data)
return LabelResponse.model_validate(label)
@router.delete("/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_label(
label_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina un tag (solo admin). Viene rimosso automaticamente da tutti i messaggi."""
svc = LabelService(db)
await svc.delete_label(current_user.tenant_id, label_id)
# ─── Tag su singolo messaggio ─────────────────────────────────────────────────
@router.get("/messages/{message_id}/labels", response_model=list[LabelResponse])
async def get_message_labels(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Elenca i tag assegnati a un messaggio."""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.get_message_labels(message_id, current_user.tenant_id)
return [LabelResponse.model_validate(l) for l in labels]
@router.put("/messages/{message_id}/labels", response_model=list[LabelResponse])
async def set_message_labels(
message_id: uuid.UUID,
data: MessageLabelSetRequest,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""
Sostituisce tutti i tag di un messaggio.
Passare una lista vuota per rimuovere tutti i tag.
"""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.set_message_labels(
message_id, current_user.tenant_id, data.label_ids
)
return [LabelResponse.model_validate(l) for l in labels]
@router.post("/messages/{message_id}/labels/add", response_model=list[LabelResponse])
async def add_message_labels(
message_id: uuid.UUID,
data: MessageLabelAddRequest,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.add_message_labels(
message_id, current_user.tenant_id, data.label_ids
)
return [LabelResponse.model_validate(l) for l in labels]
@router.post("/messages/{message_id}/labels/remove", response_model=list[LabelResponse])
async def remove_message_labels(
message_id: uuid.UUID,
data: MessageLabelRemoveRequest,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Rimuove specifici tag da un messaggio."""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.remove_message_labels(
message_id, current_user.tenant_id, data.label_ids
)
return [LabelResponse.model_validate(l) for l in labels]
# ─── Bulk labels ──────────────────────────────────────────────────────────────
@router.post("/messages/bulk-labels", response_model=MessageBulkLabelResponse)
async def bulk_labels(
data: MessageBulkLabelRequest,
current_user: CurrentUser,
db: DB,
) -> MessageBulkLabelResponse:
"""
Aggiunge o rimuove tag da più messaggi in blocco.
- action="add": aggiunge i tag ai messaggi indicati
- action="remove": rimuove i tag dai messaggi indicati
I messaggi non accessibili all'utente vengono silenziosamente ignorati.
"""
if not data.message_ids or not data.label_ids:
return MessageBulkLabelResponse(updated=0)
# Per utenti non-admin filtra per caselle accessibili
message_ids = [str(mid) for mid in data.message_ids]
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
visible = await perm_svc.get_visible_mailboxes(current_user)
visible_set = set(visible) if visible else set()
# Filtra i messaggi per caselle visibili
result = await db.execute(
select(Message.id).where(
Message.id.in_(data.message_ids),
Message.tenant_id == current_user.tenant_id,
Message.mailbox_id.in_(visible_set),
)
)
filtered_ids = list(result.scalars().all())
else:
filtered_ids = data.message_ids
svc = LabelService(db)
if data.action == "add":
updated = await svc.bulk_add_labels(
filtered_ids, current_user.tenant_id, data.label_ids
)
else:
updated = await svc.bulk_remove_labels(
filtered_ids, current_user.tenant_id, data.label_ids
)
return MessageBulkLabelResponse(updated=updated)
+47 -6
View File
@@ -23,11 +23,13 @@ from fastapi import APIRouter, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.core.exceptions import ForbiddenError, NotFoundError
from app.database import get_db
from app.dependencies import CurrentUser, DB
from app.models.label import Label
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentResponse,
@@ -107,12 +109,20 @@ async def _resolve_message(
current_user,
db: AsyncSession,
) -> Message:
"""Carica il messaggio e verifica i permessi di accesso."""
"""Carica il messaggio e verifica i permessi di accesso.
L'accesso è consentito se:
1. L'utente è admin del tenant, oppure
2. L'utente ha un permesso diretto can_read sulla casella, oppure
3. L'utente è assegnato a una Virtual Box attiva che include la casella.
"""
result = await db.execute(
select(Message).where(
select(Message)
.where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
.options(selectinload(Message.labels))
)
message = result.scalar_one_or_none()
if not message:
@@ -121,8 +131,39 @@ async def _resolve_message(
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, message.mailbox_id):
raise ForbiddenError("Accesso al messaggio non autorizzato")
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
if not has_direct_access:
# Verifica accesso tramite Virtual Box:
# l'utente deve essere assegnato a una VBox attiva
# che include la casella del messaggio.
from app.models.virtual_box import (
VirtualBox,
VirtualBoxAssignment,
virtual_box_mailboxes,
)
vbox_result = await db.execute(
select(VirtualBox.id)
.join(
VirtualBoxAssignment,
VirtualBox.id == VirtualBoxAssignment.virtual_box_id,
)
.join(
virtual_box_mailboxes,
VirtualBox.id == virtual_box_mailboxes.c.virtual_box_id,
)
.where(
VirtualBoxAssignment.user_id == current_user.id,
virtual_box_mailboxes.c.mailbox_id == message.mailbox_id,
VirtualBox.tenant_id == current_user.tenant_id,
VirtualBox.is_active == True,
)
.limit(1)
)
has_vbox_access = vbox_result.scalar_one_or_none() is not None
if not has_vbox_access:
raise ForbiddenError("Accesso al messaggio non autorizzato")
return message
@@ -160,7 +201,6 @@ async def list_messages(
# ── Filtro Virtual Box ────────────────────────────────────────────────────
vbox_rules: list = []
if vbox_id is not None:
from sqlalchemy.orm import selectinload
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment
vbox_result = await db.execute(
@@ -258,7 +298,8 @@ async def list_messages(
# Ordinamento e paginazione
q = (
q.order_by(
q.options(selectinload(Message.labels))
.order_by(
Message.received_at.desc().nullslast(),
Message.created_at.desc(),
)
+94 -30
View File
@@ -1,20 +1,22 @@
"""
Router API Invio PEC (Fase 4).
Router API Invio PEC.
Endpoint:
POST /send invia una nuova PEC (crea Message + SendJob, accoda job)
GET /send/jobs lista job di invio del tenant (paginata)
GET /send/jobs/{id} dettaglio di un singolo job
DELETE /send/jobs/{id} annulla job se ancora pending/retrying
POST /send invia una PEC via JSON (body_text, senza allegati)
POST /send/multipart invia una PEC con allegati (multipart/form-data)
GET /send/jobs lista job di invio del tenant (paginata)
GET /send/jobs/{id} dettaglio di un singolo job
DELETE /send/jobs/{id} annulla job se ancora pending/retrying
"""
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, Query, status
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
from app.core.exceptions import ForbiddenError
from app.dependencies import AdminUser, CurrentUser, DB
from app.dependencies import CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
from app.services.permission_service import PermissionService
from app.services.send_service import SendService
@@ -32,18 +34,16 @@ def _job_response(job) -> SendJobResponse:
return SendJobResponse.model_validate(job)
# ─── Endpoints ────────────────────────────────────────────────────────────────
# ─── POST /send (JSON retrocompatibile) ────────────────────────────────────
@router.post(
"",
response_model=SendJobResponse,
status_code=status.HTTP_201_CREATED,
summary="Invia una PEC",
summary="Invia una PEC (JSON, senza allegati)",
description=(
"Crea un messaggio PEC in uscita e accoda il job di invio SMTP. "
"Il job viene eseguito in background con retry automatico. "
"Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)."
"Per inviare allegati utilizzare `POST /send/multipart`."
),
)
async def create_send_job(
@@ -54,11 +54,87 @@ async def create_send_job(
svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data)
await db.commit()
# Refresh per ottenere tutti i valori default dal DB
await db.refresh(job)
return _job_response(job)
# ─── POST /send/multipart (Form + files) ─────────────────────────────────────
@router.post(
"/multipart",
response_model=SendJobResponse,
status_code=status.HTTP_201_CREATED,
summary="Invia una PEC con allegati (multipart/form-data)",
description=(
"Accetta un form multipart con:\n"
"- **data**: JSON string con i campi della PEC (stessa struttura di `POST /send`, "
" più il campo opzionale `body_html`)\n"
"- **attachments**: zero o più file allegati (max 20 MB ciascuno)\n\n"
"Carica gli allegati su MinIO e crea i record `Attachment` nel DB prima di accodare il job."
),
)
async def create_send_job_multipart(
current_user: CurrentUser,
db: DB,
data: str = Form(
...,
description="JSON con i dati della PEC (campi SendPecRequest + body_html opzionale)",
),
attachments: list[UploadFile] = File(
default=[],
description="File allegati (0 o più, max 20 MB ciascuno)",
),
) -> SendJobResponse:
# ── Parse del JSON ────────────────────────────────────────────────────────
try:
raw = json.loads(data)
pec_data = SendPecRequest.model_validate(raw)
# Estrai body_html (non presente nel modello base)
body_html: str | None = raw.get("body_html") or None
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Dati PEC non validi: {exc}",
)
# ── Leggi file allegati ───────────────────────────────────────────────────
MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB
files_data: list[dict] = []
for upload in attachments:
content = await upload.read()
if not content:
continue # Salta file vuoti
if len(content) > MAX_FILE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Il file '{upload.filename}' supera il limite di 20 MB",
)
files_data.append(
{
"filename": upload.filename or "allegato",
"content": content,
"content_type": upload.content_type or "application/octet-stream",
}
)
# ── Sovrascrive body_html se fornito ──────────────────────────────────────
if body_html:
pec_data.body_html = body_html # type: ignore[attr-defined]
svc = _svc(db)
job = await svc.create_send_job(
current_user=current_user,
data=pec_data,
attachments=files_data if files_data else None,
)
await db.commit()
await db.refresh(job)
return _job_response(job)
# ─── GET /send/jobs ────────────────────────────────────────────────────────────
@router.get(
"/jobs",
response_model=SendJobListResponse,
@@ -76,15 +152,8 @@ async def list_send_jobs(
description="Filtra per stato: pending | sending | sent | failed | retrying",
),
) -> SendJobListResponse:
"""
Elenca i job di invio del tenant.
Gli admin vedono tutti i job; gli operatori vedono solo i job
delle caselle su cui hanno permesso can_read.
"""
svc = _svc(db)
# Filtro opzionale per casella: verifica accesso se non admin
if mailbox_id and not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, mailbox_id):
@@ -105,6 +174,8 @@ async def list_send_jobs(
)
# ─── GET /send/jobs/{id} ───────────────────────────────────────────────────────
@router.get(
"/jobs/{job_id}",
response_model=SendJobResponse,
@@ -115,11 +186,9 @@ async def get_send_job(
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
"""Recupera lo stato di un singolo job di invio."""
svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id)
# Verifica accesso alla casella se non admin
if not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, job.mailbox_id):
@@ -128,6 +197,8 @@ async def get_send_job(
return _job_response(job)
# ─── DELETE /send/jobs/{id} ────────────────────────────────────────────────────
@router.delete(
"/jobs/{job_id}",
status_code=status.HTTP_204_NO_CONTENT,
@@ -138,16 +209,9 @@ async def cancel_send_job(
current_user: CurrentUser,
db: DB,
) -> None:
"""
Annulla un job di invio se è ancora in stato **pending** o **retrying**.
Non è possibile annullare un invio già partito (stato sending) o
completato (sent).
"""
svc = _svc(db)
# Verifica che l'utente possa agire su questo job
job = await svc.get_send_job(job_id, current_user.tenant_id)
if not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_send(current_user, job.mailbox_id):
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.database import engine
@@ -93,6 +93,7 @@ app.include_router(send.router, prefix=API_PREFIX)
app.include_router(ws.router, prefix=API_PREFIX)
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
app.include_router(notifications.router, prefix=API_PREFIX)
app.include_router(labels.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+5
View File
@@ -108,6 +108,11 @@ class Message(Base):
children: Mapped[list["Message"]] = relationship(
"Message", foreign_keys=[parent_message_id]
)
labels: Mapped[list["Label"]] = relationship( # type: ignore[name-defined]
"Label",
secondary="message_labels",
lazy="select",
)
__table_args__ = (
Index("idx_messages_tenant", "tenant_id"),
+55
View File
@@ -0,0 +1,55 @@
"""
Schemi Pydantic per Label (tag) e operazioni correlate.
"""
import uuid
from typing import Optional
from pydantic import BaseModel, Field
class LabelCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
class LabelUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
class LabelResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
name: str
color: Optional[str] = None
model_config = {"from_attributes": True}
# ─── Richieste per assegnazione tag a messaggi ────────────────────────────────
class MessageLabelSetRequest(BaseModel):
"""Sostituisce tutti i tag di un messaggio con quelli indicati."""
label_ids: list[uuid.UUID]
class MessageLabelAddRequest(BaseModel):
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
label_ids: list[uuid.UUID]
class MessageLabelRemoveRequest(BaseModel):
"""Rimuove specifici tag da un messaggio."""
label_ids: list[uuid.UUID]
class MessageBulkLabelRequest(BaseModel):
"""Aggiunge o rimuove tag da più messaggi in blocco."""
message_ids: list[uuid.UUID]
label_ids: list[uuid.UUID]
action: str = Field(..., pattern=r'^(add|remove)$')
class MessageBulkLabelResponse(BaseModel):
updated: int
+3
View File
@@ -8,6 +8,8 @@ from typing import Optional
from pydantic import BaseModel, model_validator
from app.schemas.label import LabelResponse
class AttachmentResponse(BaseModel):
id: uuid.UUID
@@ -50,6 +52,7 @@ class MessageResponse(BaseModel):
raw_eml_path: Optional[str] = None
created_at: datetime
updated_at: datetime
labels: list[LabelResponse] = []
@model_validator(mode="before")
@classmethod
+271
View File
@@ -0,0 +1,271 @@
"""
Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi.
"""
import uuid
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.label import Label, MessageLabel
from app.models.message import Message
from app.schemas.label import LabelCreate, LabelUpdate
class LabelService:
def __init__(self, db: AsyncSession):
self.db = db
# ─── CRUD Label ───────────────────────────────────────────────────────────
async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]:
result = await self.db.execute(
select(Label)
.where(Label.tenant_id == tenant_id)
.order_by(Label.name)
)
return list(result.scalars().all())
async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label:
result = await self.db.execute(
select(Label).where(
Label.id == label_id,
Label.tenant_id == tenant_id,
)
)
label = result.scalar_one_or_none()
if not label:
raise NotFoundError(f"Tag {label_id} non trovato")
return label
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label:
# Verifica unicità
existing = await self.db.execute(
select(Label).where(
Label.tenant_id == tenant_id,
Label.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Tag '{data.name}' già esistente")
label = Label(
tenant_id=tenant_id,
name=data.name,
color=data.color,
)
self.db.add(label)
await self.db.commit()
await self.db.refresh(label)
return label
async def update_label(
self, tenant_id: uuid.UUID, label_id: uuid.UUID, data: LabelUpdate
) -> Label:
label = await self.get_label(tenant_id, label_id)
if data.name is not None:
# Verifica unicità del nuovo nome
existing = await self.db.execute(
select(Label).where(
Label.tenant_id == tenant_id,
Label.name == data.name,
Label.id != label_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Tag '{data.name}' già esistente")
label.name = data.name
if data.color is not None:
label.color = data.color
await self.db.commit()
await self.db.refresh(label)
return label
async def delete_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> None:
label = await self.get_label(tenant_id, label_id)
await self.db.delete(label)
await self.db.commit()
# ─── Assegnazione tag a singolo messaggio ─────────────────────────────────
async def get_message_labels(
self, message_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[Label]:
result = await self.db.execute(
select(Label)
.join(MessageLabel, Label.id == MessageLabel.label_id)
.where(
MessageLabel.message_id == message_id,
Label.tenant_id == tenant_id,
)
.order_by(Label.name)
)
return list(result.scalars().all())
async def set_message_labels(
self,
message_id: uuid.UUID,
tenant_id: uuid.UUID,
label_ids: list[uuid.UUID],
) -> list[Label]:
"""Sostituisce tutti i tag di un messaggio con quelli indicati."""
# Verifica che i label appartengano al tenant
valid_ids: set[uuid.UUID] = set()
if label_ids:
result = await self.db.execute(
select(Label).where(
Label.id.in_(label_ids),
Label.tenant_id == tenant_id,
)
)
valid_ids = {lbl.id for lbl in result.scalars().all()}
# Rimuovi tutti i tag esistenti dal messaggio
await self.db.execute(
delete(MessageLabel).where(MessageLabel.message_id == message_id)
)
# Aggiungi i nuovi tag validi
for lbl_id in valid_ids:
self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id))
await self.db.commit()
return await self.get_message_labels(message_id, tenant_id)
async def add_message_labels(
self,
message_id: uuid.UUID,
tenant_id: uuid.UUID,
label_ids: list[uuid.UUID],
) -> list[Label]:
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
if not label_ids:
return await self.get_message_labels(message_id, tenant_id)
# Verifica appartenenza al tenant
result = await self.db.execute(
select(Label).where(
Label.id.in_(label_ids),
Label.tenant_id == tenant_id,
)
)
valid_labels = list(result.scalars().all())
# Carica tag esistenti per evitare duplicati
existing_result = await self.db.execute(
select(MessageLabel.label_id).where(
MessageLabel.message_id == message_id
)
)
existing_ids = set(existing_result.scalars().all())
for lbl in valid_labels:
if lbl.id not in existing_ids:
self.db.add(MessageLabel(message_id=message_id, label_id=lbl.id))
await self.db.commit()
return await self.get_message_labels(message_id, tenant_id)
async def remove_message_labels(
self,
message_id: uuid.UUID,
tenant_id: uuid.UUID,
label_ids: list[uuid.UUID],
) -> list[Label]:
"""Rimuove specifici tag da un messaggio."""
if label_ids:
await self.db.execute(
delete(MessageLabel).where(
MessageLabel.message_id == message_id,
MessageLabel.label_id.in_(label_ids),
)
)
await self.db.commit()
return await self.get_message_labels(message_id, tenant_id)
# ─── Azioni bulk ──────────────────────────────────────────────────────────
async def bulk_add_labels(
self,
message_ids: list[uuid.UUID],
tenant_id: uuid.UUID,
label_ids: list[uuid.UUID],
) -> int:
"""Aggiunge tag a più messaggi in blocco."""
if not label_ids or not message_ids:
return 0
# Verifica label del tenant
lbl_result = await self.db.execute(
select(Label).where(
Label.id.in_(label_ids),
Label.tenant_id == tenant_id,
)
)
valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()]
# Verifica messaggi del tenant
msg_result = await self.db.execute(
select(Message.id).where(
Message.id.in_(message_ids),
Message.tenant_id == tenant_id,
)
)
valid_message_ids = list(msg_result.scalars().all())
if not valid_label_ids or not valid_message_ids:
return 0
# Carica coppie esistenti per evitare duplicati
existing_result = await self.db.execute(
select(MessageLabel).where(
MessageLabel.message_id.in_(valid_message_ids),
MessageLabel.label_id.in_(valid_label_ids),
)
)
existing_pairs = {
(ml.message_id, ml.label_id) for ml in existing_result.scalars().all()
}
for msg_id in valid_message_ids:
for lbl_id in valid_label_ids:
if (msg_id, lbl_id) not in existing_pairs:
self.db.add(MessageLabel(message_id=msg_id, label_id=lbl_id))
await self.db.commit()
return len(valid_message_ids)
async def bulk_remove_labels(
self,
message_ids: list[uuid.UUID],
tenant_id: uuid.UUID,
label_ids: list[uuid.UUID],
) -> int:
"""Rimuove tag da più messaggi in blocco."""
if not label_ids or not message_ids:
return 0
# Verifica messaggi del tenant
msg_result = await self.db.execute(
select(Message.id).where(
Message.id.in_(message_ids),
Message.tenant_id == tenant_id,
)
)
valid_message_ids = list(msg_result.scalars().all())
if not valid_message_ids:
return 0
await self.db.execute(
delete(MessageLabel).where(
MessageLabel.message_id.in_(valid_message_ids),
MessageLabel.label_id.in_(label_ids),
)
)
await self.db.commit()
return len(valid_message_ids)
+131 -21
View File
@@ -1,18 +1,20 @@
"""
SendService logica di business per l'invio PEC (Fase 4).
SendService logica di business per l'invio PEC.
Responsabilità:
1. Valida permessi (check_can_send) sulla casella selezionata
2. Crea il record Message (direction=outbound, state=queued)
3. Crea il record SendJob (status=pending)
4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
5. Ritorna SendJobResponse
3. [Opzionale] Carica gli allegati su MinIO e crea i record Attachment
4. Crea il record SendJob (status=pending)
5. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
6. Ritorna SendJob
Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura
del raw EML su MinIO.
"""
import asyncio
import hashlib
import uuid
from datetime import datetime, timezone
@@ -21,13 +23,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError, NotFoundError
from app.models.mailbox import Mailbox
from app.models.message import Message, SendJob
from app.models.message import Attachment, Message, SendJob
from app.models.user import User
from app.schemas.send import SendJobResponse, SendPecRequest
from app.services.permission_service import PermissionService
# ─── Pool arq (singleton lazy) ────────────────────────────────────────────────
# Usato per fare enqueue dei job send_pec senza avviare un worker nel backend.
_arq_pool = None
_arq_pool_lock = asyncio.Lock()
@@ -68,6 +69,40 @@ async def close_arq_pool() -> None:
_arq_pool = None
# ─── Helper: HTML → testo semplice ────────────────────────────────────────────
def _html_to_plain(html: str) -> str:
"""
Converte HTML in testo semplice usando html.parser della stdlib.
Usato per generare la parte text/plain quando viene fornito solo body_html.
"""
from html.parser import HTMLParser
BLOCK_TAGS = {
"p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "pre", "hr", "tr",
}
class _Extractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._parts: list[str] = []
def handle_data(self, data: str) -> None:
self._parts.append(data)
def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
if tag.lower() in BLOCK_TAGS:
self._parts.append("\n")
def text(self) -> str:
return "".join(self._parts).strip()
extractor = _Extractor()
extractor.feed(html)
return extractor.text()
# ─── SendService ──────────────────────────────────────────────────────────────
class SendService:
@@ -80,20 +115,22 @@ class SendService:
self,
current_user: User,
data: SendPecRequest,
attachments: list[dict] | None = None,
) -> SendJob:
"""
Crea Message + SendJob e accoda il job di invio.
Crea Message + (Attachment*) + SendJob e accoda il job di invio.
Args:
current_user: utente autenticato che richiede l'invio
data: dati della PEC da inviare
attachments: lista opzionale di dict {filename, content: bytes, content_type}
Returns:
SendJob appena creato
Raises:
NotFoundError: casella non trovata o non appartenente al tenant
ForbiddenError: utente senza can_send sulla casella
NotFoundError: casella non trovata o non appartenente al tenant
ForbiddenError: utente senza can_send sulla casella
"""
# ── Verifica casella ──────────────────────────────────────────────────
mailbox = await self.db.get(Mailbox, data.mailbox_id)
@@ -105,7 +142,6 @@ class SendService:
raise NotFoundError("casella PEC mittente")
if mailbox.status != "active":
from app.core.exceptions import ForbiddenError
raise ForbiddenError(
f"La casella è in stato '{mailbox.status}' e non può inviare"
)
@@ -118,8 +154,18 @@ class SendService:
"Non hai il permesso di inviare da questa casella"
)
# ── Ricava body_text ──────────────────────────────────────────────────
body_text = data.body_text or ""
body_html: str | None = getattr(data, "body_html", None) or data.body_html # type: ignore[attr-defined]
# Se il body_text è vuoto ma abbiamo HTML, genera il plain text
if not body_text and body_html:
body_text = _html_to_plain(body_html)
# ── Crea il messaggio outbound ────────────────────────────────────────
now = datetime.now(tz=timezone.utc)
has_files = bool(attachments)
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
@@ -130,9 +176,9 @@ class SendService:
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [],
body_text=data.body_text or "",
body_html=data.body_html,
has_attachments=False, # allegati in Fase 5
body_text=body_text,
body_html=body_html,
has_attachments=has_files,
sent_at=None,
received_at=None,
)
@@ -144,7 +190,16 @@ class SendService:
message.parent_message_id = data.reply_to_message_id
self.db.add(message)
await self.db.flush()
await self.db.flush() # Ottieni message.id
# ── Carica allegati su MinIO e crea record Attachment ─────────────────
if has_files:
await self._upload_attachments(
message=message,
attachments=attachments, # type: ignore[arg-type]
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
)
# ── Crea il SendJob ───────────────────────────────────────────────────
job = SendJob(
@@ -171,11 +226,70 @@ class SendService:
f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. "
"Il job resterà in stato 'pending' per pickup manuale."
)
# Non alziamo eccezione: il job è nel DB e verrà processato
# dal cron di polling se disponibile
return job
# ── Upload allegati ────────────────────────────────────────────────────────
async def _upload_attachments(
self,
message: Message,
attachments: list[dict],
tenant_id: uuid.UUID,
mailbox_id: uuid.UUID,
) -> None:
"""
Carica ogni allegato su MinIO e crea il record Attachment nel DB.
Gestisce gli errori di upload singolarmente: un allegato che non
riesce a caricarsi non blocca gli altri.
"""
from app.core.logging import get_logger
from app.storage.minio_client import upload_attachment as minio_upload
logger = get_logger(__name__)
for att in attachments:
filename: str = att.get("filename") or "allegato"
content: bytes = att.get("content") or b""
content_type: str = att.get("content_type") or "application/octet-stream"
if not content:
continue
try:
storage_path = await minio_upload(
tenant_id=str(tenant_id),
mailbox_id=str(mailbox_id),
message_id=str(message.id),
filename=filename,
content=content,
content_type=content_type,
)
checksum = hashlib.sha256(content).hexdigest()
attachment_record = Attachment(
tenant_id=tenant_id,
message_id=message.id,
filename=filename,
content_type=content_type,
size_bytes=len(content),
storage_path=storage_path,
checksum_sha256=checksum,
)
self.db.add(attachment_record)
logger.debug(
f"[send_service] Allegato caricato: {storage_path} "
f"({len(content)} bytes)"
)
except Exception as upload_err:
logger.error(
f"[send_service] Errore upload allegato '{filename}': {upload_err}"
)
# Non sollevare: l'invio continuerà senza questo allegato
await self.db.flush()
# ── Lista job di invio ────────────────────────────────────────────────────
async def list_send_jobs(
@@ -226,10 +340,7 @@ class SendService:
job_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> SendJob:
"""
Annulla un job di invio se è ancora in stato 'pending'.
Non cancella il messaggio associato.
"""
"""Annulla un job di invio se è ancora in stato 'pending' o 'retrying'."""
job = await self.get_send_job(job_id, tenant_id)
if job.status not in ("pending", "retrying"):
@@ -240,7 +351,6 @@ class SendService:
job.status = "failed"
job.last_error = "Annullato dall'utente"
# Aggiorna anche il messaggio
if job.message_id:
msg = await self.db.get(Message, job.message_id)
if msg and msg.state in ("queued", "draft"):
+33 -9
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.models.mailbox import Mailbox
from app.models.user import User
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule, virtual_box_mailboxes
from app.schemas.virtual_box import (
AssignedUserResponse,
VirtualBoxCreate,
@@ -65,10 +65,15 @@ class VirtualBoxService:
)
self.db.add(rule)
# Associa le caselle reali
# Associa le caselle reali (INSERT diretto sulla tabella di associazione
# per evitare MissingGreenlet con SQLAlchemy async)
if data.mailbox_ids:
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
valid_mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
if valid_mailboxes:
await self.db.execute(
virtual_box_mailboxes.insert(),
[{"virtual_box_id": vbox.id, "mailbox_id": mb.id} for mb in valid_mailboxes],
)
await self.db.flush()
return await self._load_full(vbox.id)
@@ -136,10 +141,19 @@ class VirtualBoxService:
if data.is_active is not None:
vbox.is_active = data.is_active
# Aggiorna le caselle associate se fornito
# Aggiorna le caselle associate se fornito (INSERT/DELETE diretti)
if data.mailbox_ids is not None:
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
await self.db.execute(
virtual_box_mailboxes.delete().where(
virtual_box_mailboxes.c.virtual_box_id == vbox_id
)
)
valid_mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
if valid_mailboxes:
await self.db.execute(
virtual_box_mailboxes.insert(),
[{"virtual_box_id": vbox_id, "mailbox_id": mb.id} for mb in valid_mailboxes],
)
await self.db.flush()
return await self._load_full(vbox_id)
@@ -196,8 +210,18 @@ class VirtualBoxService:
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
# Sostituzione completa con INSERT/DELETE diretti
await self.db.execute(
virtual_box_mailboxes.delete().where(
virtual_box_mailboxes.c.virtual_box_id == vbox_id
)
)
valid_mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id)
if valid_mailboxes:
await self.db.execute(
virtual_box_mailboxes.insert(),
[{"virtual_box_id": vbox_id, "mailbox_id": mb.id} for mb in valid_mailboxes],
)
await self.db.flush()
return await self._load_full(vbox_id)
+1
View File
@@ -0,0 +1 @@
# Storage utilities per il backend (MinIO/S3)
+89
View File
@@ -0,0 +1,89 @@
"""
Client MinIO per il backend upload allegati outbound.
Percorso allegati outbound:
tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{message_id}/{filename}
"""
import io
import logging
import re
from functools import lru_cache
from miniopy_async import Minio
from app.config import get_settings
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def get_minio_client() -> Minio:
"""Restituisce l'istanza singleton del client MinIO."""
settings = get_settings()
return Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
async def upload_attachment(
tenant_id: str,
mailbox_id: str,
message_id: str,
filename: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
"""
Carica un allegato outbound su MinIO.
Args:
tenant_id: UUID del tenant (stringa)
mailbox_id: UUID della casella mittente
message_id: UUID del messaggio associato
filename: Nome file originale
content: Byte del file
content_type: MIME type
Returns:
Percorso oggetto su MinIO (senza nome bucket)
"""
settings = get_settings()
client = get_minio_client()
bucket = settings.minio_bucket
safe_filename = _sanitize_filename(filename)
object_path = (
f"tenants/{tenant_id}/mailboxes/{mailbox_id}"
f"/attachments/{message_id}/{safe_filename}"
)
data_stream = io.BytesIO(content)
await client.put_object(
bucket_name=bucket,
object_name=object_path,
data=data_stream,
length=len(content),
content_type=content_type,
)
logger.debug(
f"Allegato outbound caricato: {object_path} "
f"({len(content)} bytes, {content_type})"
)
return object_path
def _sanitize_filename(filename: str) -> str:
"""Sanitizza il nome file per uso sicuro come path MinIO."""
safe = filename.replace("/", "_").replace("\\", "_").replace("\x00", "")
safe = re.sub(r"[^\w.\-() ]", "_", safe, flags=re.UNICODE)
if len(safe) > 200:
parts = safe.rsplit(".", 1)
if len(parts) == 2:
safe = parts[0][:196] + "." + parts[1]
else:
safe = safe[:200]
return safe or "attachment"
+858 -3
View File
@@ -2150,6 +2150,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -2180,6 +2186,20 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@tanstack/query-core": {
"version": "5.91.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.0.tgz",
@@ -2233,6 +2253,505 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz",
"integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.4.tgz",
"integrity": "sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.4.tgz",
"integrity": "sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.4.tgz",
"integrity": "sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.4.tgz",
"integrity": "sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.4.tgz",
"integrity": "sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.4.tgz",
"integrity": "sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-color": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.20.4.tgz",
"integrity": "sha512-+OT9wWEJnqoWmzfqPYt0oWm8LZcH+D44Z3jA2TNzBj4tLGQ2YPxN2SyS12AlRi7MuguVT7utFy7qDXrfir8eUA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "^3.20.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.4.tgz",
"integrity": "sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.4.tgz",
"integrity": "sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.4"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.4.tgz",
"integrity": "sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-font-family": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-font-family/-/extension-font-family-3.20.4.tgz",
"integrity": "sha512-u5HjpNVBK7N9glR4Sz/HyVvTTAprEiion0oyyBWPBlgZvLrJta0zNvhfwG9ZUoubvqou3fBRbZwVosfonN2fAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "^3.20.4"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.4.tgz",
"integrity": "sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.4"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.4.tgz",
"integrity": "sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.4.tgz",
"integrity": "sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.4.tgz",
"integrity": "sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz",
"integrity": "sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.4.tgz",
"integrity": "sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz",
"integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.4.tgz",
"integrity": "sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.4.tgz",
"integrity": "sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.4.tgz",
"integrity": "sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.4.tgz",
"integrity": "sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.4.tgz",
"integrity": "sha512-GB0KWtqm83YHG8cnqBLijvUBm+xvLfQHDfFRRH2fb3EzH3eIsM9jKRC31ADT27RSV1zVpHMFGcP3/pWpdrN1Lw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.4"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.4.tgz",
"integrity": "sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.4.tgz",
"integrity": "sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.20.4.tgz",
"integrity": "sha512-6ZuRyClIyCimXu+S5LQ54DueEsYg5VOVOmubOVbG+WAjM9svn9Z8gv2sNDah2yEqXrX06B02zYcSyMiD7CHbfA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.4.tgz",
"integrity": "sha512-PvW0Ja7ahWpo4bRuR8YCCVv4PH8lXjzhzlBAa4bMbsumOg+GbhX8Su7fwqd+IIPrHqfPXz9HTBMApSfzP6/08A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.4.tgz",
"integrity": "sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.4.tgz",
"integrity": "sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz",
"integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.4.tgz",
"integrity": "sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.20.4",
"@tiptap/extension-floating-menu": "^3.20.4"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.4.tgz",
"integrity": "sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/extension-blockquote": "^3.20.4",
"@tiptap/extension-bold": "^3.20.4",
"@tiptap/extension-bullet-list": "^3.20.4",
"@tiptap/extension-code": "^3.20.4",
"@tiptap/extension-code-block": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-dropcursor": "^3.20.4",
"@tiptap/extension-gapcursor": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-heading": "^3.20.4",
"@tiptap/extension-horizontal-rule": "^3.20.4",
"@tiptap/extension-italic": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-list": "^3.20.4",
"@tiptap/extension-list-item": "^3.20.4",
"@tiptap/extension-list-keymap": "^3.20.4",
"@tiptap/extension-ordered-list": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-strike": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/extensions": "^3.20.4",
"@tiptap/pm": "^3.20.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2285,6 +2804,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -2299,14 +2840,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2317,12 +2856,17 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2485,6 +3029,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -2803,6 +3353,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2910,6 +3466,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3011,6 +3579,18 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -3031,6 +3611,15 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3396,6 +3985,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -3444,6 +4048,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3453,6 +4074,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3573,6 +4200,12 @@
"node": ">= 6"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3832,12 +4465,216 @@
"dev": true,
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.7",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz",
"integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
@@ -4137,6 +4974,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -4450,6 +5293,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -4696,6 +5545,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+854 -3
View File
@@ -21,6 +21,16 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-font-family": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/extension-text-align": "^3.20.4",
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -2569,6 +2579,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -2988,6 +3004,505 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz",
"integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.4.tgz",
"integrity": "sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.4.tgz",
"integrity": "sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.4.tgz",
"integrity": "sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.4.tgz",
"integrity": "sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.4.tgz",
"integrity": "sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.4.tgz",
"integrity": "sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-color": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.20.4.tgz",
"integrity": "sha512-+OT9wWEJnqoWmzfqPYt0oWm8LZcH+D44Z3jA2TNzBj4tLGQ2YPxN2SyS12AlRi7MuguVT7utFy7qDXrfir8eUA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "^3.20.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.4.tgz",
"integrity": "sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.4.tgz",
"integrity": "sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.4"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.4.tgz",
"integrity": "sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-font-family": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-font-family/-/extension-font-family-3.20.4.tgz",
"integrity": "sha512-u5HjpNVBK7N9glR4Sz/HyVvTTAprEiion0oyyBWPBlgZvLrJta0zNvhfwG9ZUoubvqou3fBRbZwVosfonN2fAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "^3.20.4"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.4.tgz",
"integrity": "sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.4"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.4.tgz",
"integrity": "sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.4.tgz",
"integrity": "sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.4.tgz",
"integrity": "sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz",
"integrity": "sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.4.tgz",
"integrity": "sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz",
"integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.4.tgz",
"integrity": "sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.4.tgz",
"integrity": "sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.4.tgz",
"integrity": "sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.4"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.4.tgz",
"integrity": "sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.4.tgz",
"integrity": "sha512-GB0KWtqm83YHG8cnqBLijvUBm+xvLfQHDfFRRH2fb3EzH3eIsM9jKRC31ADT27RSV1zVpHMFGcP3/pWpdrN1Lw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.4"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.4.tgz",
"integrity": "sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.4.tgz",
"integrity": "sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.20.4.tgz",
"integrity": "sha512-6ZuRyClIyCimXu+S5LQ54DueEsYg5VOVOmubOVbG+WAjM9svn9Z8gv2sNDah2yEqXrX06B02zYcSyMiD7CHbfA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.4.tgz",
"integrity": "sha512-PvW0Ja7ahWpo4bRuR8YCCVv4PH8lXjzhzlBAa4bMbsumOg+GbhX8Su7fwqd+IIPrHqfPXz9HTBMApSfzP6/08A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.4.tgz",
"integrity": "sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.4.tgz",
"integrity": "sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz",
"integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.4.tgz",
"integrity": "sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.20.4",
"@tiptap/extension-floating-menu": "^3.20.4"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.4.tgz",
"integrity": "sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/extension-blockquote": "^3.20.4",
"@tiptap/extension-bold": "^3.20.4",
"@tiptap/extension-bullet-list": "^3.20.4",
"@tiptap/extension-code": "^3.20.4",
"@tiptap/extension-code-block": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-dropcursor": "^3.20.4",
"@tiptap/extension-gapcursor": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-heading": "^3.20.4",
"@tiptap/extension-horizontal-rule": "^3.20.4",
"@tiptap/extension-italic": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-list": "^3.20.4",
"@tiptap/extension-list-item": "^3.20.4",
"@tiptap/extension-list-keymap": "^3.20.4",
"@tiptap/extension-ordered-list": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-strike": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/extensions": "^3.20.4",
"@tiptap/pm": "^3.20.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3040,6 +3555,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -3054,14 +3591,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -3072,12 +3607,17 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3240,6 +3780,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3558,6 +4104,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3665,6 +4217,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3766,6 +4330,18 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -3786,6 +4362,15 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4166,6 +4751,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4214,6 +4814,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4223,6 +4840,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4343,6 +4966,12 @@
"node": ">= 6"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -4602,12 +5231,216 @@
"dev": true,
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.7",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz",
"integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
@@ -4907,6 +5740,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5220,6 +6059,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -5481,6 +6326,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+25 -15
View File
@@ -13,7 +13,31 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-font-family": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/extension-text-align": "^3.20.4",
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.441.0",
@@ -24,21 +48,7 @@
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.2",
"tailwind-merge": "^2.5.2",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"class-variance-authority": "^0.7.0"
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.47.2",
+59
View File
@@ -0,0 +1,59 @@
import apiClient from './client'
import type {
LabelCreate,
LabelResponse,
LabelUpdate,
MessageBulkLabelRequest,
MessageBulkLabelResponse,
MessageLabelAddRequest,
MessageLabelRemoveRequest,
MessageLabelSetRequest,
} from '@/types/api.types'
export const labelsApi = {
// ─── CRUD Tag ─────────────────────────────────────────────────────────────
list: () =>
apiClient.get<LabelResponse[]>('/labels').then((r) => r.data),
create: (data: LabelCreate) =>
apiClient.post<LabelResponse>('/labels', data).then((r) => r.data),
update: (id: string, data: LabelUpdate) =>
apiClient.patch<LabelResponse>(`/labels/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/labels/${id}`).then((r) => r.data),
// ─── Tag su singolo messaggio ─────────────────────────────────────────────
getMessageLabels: (messageId: string) =>
apiClient
.get<LabelResponse[]>(`/messages/${messageId}/labels`)
.then((r) => r.data),
/** Sostituisce tutti i tag di un messaggio. */
setMessageLabels: (messageId: string, data: MessageLabelSetRequest) =>
apiClient
.put<LabelResponse[]>(`/messages/${messageId}/labels`, data)
.then((r) => r.data),
/** Aggiunge tag a un messaggio senza rimuovere quelli esistenti. */
addMessageLabels: (messageId: string, data: MessageLabelAddRequest) =>
apiClient
.post<LabelResponse[]>(`/messages/${messageId}/labels/add`, data)
.then((r) => r.data),
/** Rimuove specifici tag da un messaggio. */
removeMessageLabels: (messageId: string, data: MessageLabelRemoveRequest) =>
apiClient
.post<LabelResponse[]>(`/messages/${messageId}/labels/remove`, data)
.then((r) => r.data),
// ─── Bulk ─────────────────────────────────────────────────────────────────
bulkLabels: (data: MessageBulkLabelRequest) =>
apiClient
.post<MessageBulkLabelResponse>('/messages/bulk-labels', data)
.then((r) => r.data),
}
+19 -2
View File
@@ -69,8 +69,25 @@ export const messagesApi = {
getAttachments: (id: string) =>
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
getAttachmentUrl: (messageId: string, attachmentId: string) =>
`/api/v1/messages/${messageId}/attachments/${attachmentId}/download`,
/**
* Scarica un allegato autenticato e lo salva localmente.
* Utilizza apiClient (con Bearer token) per evitare il 401 che si ottiene
* navigando direttamente verso l'URL con un <a href>.
*/
downloadAttachment: async (messageId: string, attachmentId: string, filename: string): Promise<void> => {
const response = await apiClient.get(
`/messages/${messageId}/attachments/${attachmentId}/download`,
{ responseType: 'blob' },
)
const blobUrl = window.URL.createObjectURL(new Blob([response.data]))
const anchor = document.createElement('a')
anchor.href = blobUrl
anchor.setAttribute('download', filename)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.URL.revokeObjectURL(blobUrl)
},
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
+23
View File
@@ -12,10 +12,33 @@ export interface SendJobFilters {
status?: string
}
/** Payload esteso per l'invio multipart (include body_html) */
export interface SendPecMultipartRequest extends SendPecRequest {
body_html?: string
}
export const sendApi = {
/** Invio PEC semplice (JSON, senza allegati) retrocompatibile */
send: (data: SendPecRequest) =>
apiClient.post<SendJobResponse>('/send', data).then((r) => r.data),
/**
* Invio PEC con allegati tramite multipart/form-data.
*
* Il campo `data` viene serializzato come JSON string;
* i file vengono appesi come `attachments[]`.
*/
sendMultipart: (data: SendPecMultipartRequest, files: File[] = []) => {
const formData = new FormData()
formData.append('data', JSON.stringify(data))
files.forEach((file) => formData.append('attachments', file))
return apiClient
.post<SendJobResponse>('/send/multipart', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((r) => r.data)
},
listJobs: (filters: SendJobFilters = {}) =>
apiClient.get<SendJobListResponse>('/send/jobs', { params: filters }).then((r) => r.data),
@@ -0,0 +1,517 @@
/**
* RichTextEditor editor di testo ricco in stile Roundcube.
*
* Usa TipTap (ProseMirror) con estensioni per:
* - Formattazione testo: grassetto, corsivo, sottolineato, barrato
* - Colore testo (palette + picker custom)
* - Titoli H1/H2/H3
* - Elenchi puntati, numerati, citazioni
* - Allineamento testo
* - Collegamento ipertestuale
* - Undo/Redo
* - Rimuovi formattazione
*/
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import UnderlineExt from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import { TextStyle } from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import { useEffect, useCallback, useState, useRef } from 'react'
import {
Bold,
Italic,
Underline,
Strikethrough,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Quote,
Link as LinkIcon,
Link2Off,
Undo2,
Redo2,
Baseline,
Eraser,
} from 'lucide-react'
// ─── Tipi ────────────────────────────────────────────────────────────────────
interface RichTextEditorProps {
value: string
onChange: (html: string) => void
placeholder?: string
minHeight?: string
}
// ─── Palette colori ───────────────────────────────────────────────────────────
const COLOR_PALETTE = [
// Grigi
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff',
// Vivaci
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
// Chiari
'#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc',
// Standard
'#ea4335', '#fbbc04', '#34a853', '#4285f4', '#9334e6', '#c2185b', '#e65100', '#00838f',
]
// ─── Sub-componenti toolbar ───────────────────────────────────────────────────
interface ToolbarButtonProps {
onClick: () => void
isActive?: boolean
disabled?: boolean
title: string
children: React.ReactNode
}
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className={[
'flex items-center justify-center w-7 h-7 rounded text-sm transition-colors select-none',
isActive
? 'bg-primary/15 text-primary'
: 'text-foreground hover:bg-muted',
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{children}
</button>
)
}
function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
}
// ─── Color Picker ─────────────────────────────────────────────────────────────
interface ColorPickerProps {
currentColor?: string
onSelect: (color: string) => void
onClose: () => void
}
function ColorPicker({ currentColor, onSelect, onClose }: ColorPickerProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
// Delay to avoid closing immediately on the button click that opened us
const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50)
return () => {
clearTimeout(timer)
document.removeEventListener('mousedown', handler)
}
}, [onClose])
return (
<div
ref={ref}
className="absolute z-50 top-full left-0 mt-1 p-2.5 bg-background border rounded-lg shadow-xl min-w-max"
>
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Colore testo</div>
<div className="grid grid-cols-8 gap-1 mb-2">
{COLOR_PALETTE.map((color) => (
<button
key={color}
type="button"
className={[
'w-5 h-5 rounded-sm border transition-transform hover:scale-110',
currentColor === color ? 'ring-2 ring-primary ring-offset-1' : 'border-border/40',
].join(' ')}
style={{ backgroundColor: color }}
onClick={() => { onSelect(color); onClose() }}
title={color}
/>
))}
</div>
<div className="flex items-center gap-2 pt-1.5 border-t">
<label className="text-xs text-muted-foreground">Personalizzato:</label>
<input
type="color"
className="w-7 h-6 rounded cursor-pointer border border-border"
defaultValue={currentColor || '#000000'}
onChange={(e) => onSelect(e.target.value)}
/>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline ml-auto"
onClick={() => { onSelect(''); onClose() }}
>
Rimuovi
</button>
</div>
</div>
)
}
// ─── Link Dialog ──────────────────────────────────────────────────────────────
interface LinkDialogProps {
currentUrl?: string
onConfirm: (url: string) => void
onRemove: () => void
onClose: () => void
}
function LinkDialog({ currentUrl, onConfirm, onRemove, onClose }: LinkDialogProps) {
const [url, setUrl] = useState(currentUrl || 'https://')
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 50)
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
setTimeout(() => document.addEventListener('mousedown', handler), 50)
return () => {
clearTimeout(timer)
document.removeEventListener('mousedown', handler)
}
}, [onClose])
const confirm = useCallback(() => {
if (url && url !== 'https://') {
onConfirm(url.startsWith('http') ? url : `https://${url}`)
}
onClose()
}, [url, onConfirm, onClose])
return (
<div
ref={ref}
className="absolute z-50 top-full left-0 mt-1 p-3 bg-background border rounded-lg shadow-xl w-80"
>
<div className="text-xs text-muted-foreground mb-2 font-medium">
{currentUrl ? 'Modifica collegamento' : 'Inserisci collegamento'}
</div>
<div className="flex gap-2">
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://esempio.it"
className="flex-1 text-sm border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring bg-background"
onKeyDown={(e) => {
if (e.key === 'Enter') confirm()
if (e.key === 'Escape') onClose()
}}
/>
<button
type="button"
className="px-3 py-1.5 bg-primary text-primary-foreground rounded text-sm font-medium hover:bg-primary/90"
onClick={confirm}
>
OK
</button>
{currentUrl && (
<button
type="button"
className="p-1.5 bg-destructive/10 text-destructive rounded hover:bg-destructive/20"
onClick={() => { onRemove(); onClose() }}
title="Rimuovi collegamento"
>
<Link2Off className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
// ─── Toolbar principale ───────────────────────────────────────────────────────
interface ToolbarProps {
editor: ReturnType<typeof useEditor>
}
function Toolbar({ editor }: ToolbarProps) {
const [showColorPicker, setShowColorPicker] = useState(false)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const colorRef = useRef<HTMLDivElement>(null)
const linkRef = useRef<HTMLDivElement>(null)
if (!editor) return null
const currentColor = editor.getAttributes('textStyle').color as string | undefined
const currentLinkUrl = editor.getAttributes('link').href as string | undefined
return (
<div className="flex flex-wrap items-center gap-0.5 p-1.5 bg-muted/40 border-b overflow-x-auto">
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annulla (Ctrl+Z)"
>
<Undo2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Ripeti (Ctrl+Y)"
>
<Redo2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Formattazione di base */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Grassetto (Ctrl+B)"
>
<Bold className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Corsivo (Ctrl+I)"
>
<Italic className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Sottolineato (Ctrl+U)"
>
<Underline className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Barrato"
>
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Colore testo */}
<div className="relative" ref={colorRef}>
<ToolbarButton
onClick={() => setShowColorPicker((v) => !v)}
isActive={showColorPicker}
title="Colore testo"
>
<span className="flex flex-col items-center leading-none">
<Baseline className="h-3 w-3" />
<span
className="block h-[3px] w-4 rounded-sm mt-0.5"
style={{ backgroundColor: currentColor || '#000000' }}
/>
</span>
</ToolbarButton>
{showColorPicker && (
<ColorPicker
currentColor={currentColor}
onSelect={(color) => {
if (color) {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().unsetColor().run()
}
}}
onClose={() => setShowColorPicker(false)}
/>
)}
</div>
{/* Rimuovi formattazione */}
<ToolbarButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Rimuovi formattazione"
>
<Eraser className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Titoli */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Titolo 1"
>
<span className="text-[11px] font-bold leading-none">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Titolo 2"
>
<span className="text-[11px] font-bold leading-none">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Titolo 3"
>
<span className="text-[11px] font-bold leading-none">H3</span>
</ToolbarButton>
<ToolbarSeparator />
{/* Elenchi */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Elenco puntato"
>
<List className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Elenco numerato"
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Citazione"
>
<Quote className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Allineamento */}
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title="Allinea a sinistra"
>
<AlignLeft className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title="Centra"
>
<AlignCenter className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
title="Allinea a destra"
>
<AlignRight className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
isActive={editor.isActive({ textAlign: 'justify' })}
title="Giustifica"
>
<AlignJustify className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Link */}
<div className="relative" ref={linkRef}>
<ToolbarButton
onClick={() => setShowLinkDialog((v) => !v)}
isActive={editor.isActive('link') || showLinkDialog}
title={editor.isActive('link') ? 'Modifica collegamento' : 'Inserisci collegamento'}
>
<LinkIcon className="h-3.5 w-3.5" />
</ToolbarButton>
{showLinkDialog && (
<LinkDialog
currentUrl={currentLinkUrl}
onConfirm={(url) =>
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
onRemove={() => editor.chain().focus().unsetLink().run()}
onClose={() => setShowLinkDialog(false)}
/>
)}
</div>
</div>
)
}
// ─── Componente principale ────────────────────────────────────────────────────
export function RichTextEditor({
value,
onChange,
placeholder = 'Scrivi il testo della PEC...',
minHeight = '260px',
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
UnderlineExt,
TextStyle,
Color,
TextAlign.configure({
types: ['heading', 'paragraph'],
defaultAlignment: 'left',
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'rte-link',
rel: 'noopener noreferrer',
},
}),
Placeholder.configure({ placeholder }),
],
content: value,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editorProps: {
attributes: {
class: 'rte-content focus:outline-none',
},
},
})
// Sync da valore esterno (es. modifica replyTo dopo il mount)
useEffect(() => {
if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return (
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 bg-background">
<Toolbar editor={editor} />
<EditorContent
editor={editor}
style={{ minHeight }}
className="px-4 py-3 overflow-y-auto"
/>
</div>
)
}
@@ -0,0 +1,115 @@
/**
* TagBadge badge colorato per un singolo tag/label.
*
* Mostra il nome del tag con il colore di sfondo configurato.
* Se `onRemove` è fornito, mostra un pulsante × per rimuovere il tag.
*/
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { LabelResponse } from '@/types/api.types'
// Colore di default quando il tag non ha colore configurato
const DEFAULT_COLOR = '#6b7280'
/**
* Calcola il colore del testo (bianco o nero) in base alla luminosità
* del colore di sfondo per garantire un contrasto leggibile.
*/
function getTextColor(hexColor: string): string {
const hex = hexColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
// Formula luminosità relativa (WCAG)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5 ? '#1f2937' : '#ffffff'
}
interface TagBadgeProps {
label: LabelResponse
onRemove?: () => void
size?: 'sm' | 'md'
className?: string
}
export function TagBadge({ label, onRemove, size = 'sm', className }: TagBadgeProps) {
const bgColor = label.color || DEFAULT_COLOR
const textColor = getTextColor(bgColor)
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full font-medium transition-all',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
className,
)}
style={{ backgroundColor: bgColor, color: textColor }}
title={label.name}
>
<span className="truncate max-w-[120px]">{label.name}</span>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="flex-shrink-0 rounded-full p-0.5 hover:opacity-70 transition-opacity"
style={{ color: textColor }}
aria-label={`Rimuovi tag ${label.name}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
)
}
/**
* TagBadgeList mostra una lista compatta di tag badge.
* Se ci sono più tag del limite `maxVisible`, mostra un badge "+N".
*/
interface TagBadgeListProps {
labels: LabelResponse[]
onRemove?: (labelId: string) => void
maxVisible?: number
size?: 'sm' | 'md'
className?: string
}
export function TagBadgeList({
labels,
onRemove,
maxVisible = 3,
size = 'sm',
className,
}: TagBadgeListProps) {
if (!labels || labels.length === 0) return null
const visible = labels.slice(0, maxVisible)
const overflow = labels.length - maxVisible
return (
<div className={cn('flex items-center gap-1 flex-wrap', className)}>
{visible.map((label) => (
<TagBadge
key={label.id}
label={label}
onRemove={onRemove ? () => onRemove(label.id) : undefined}
size={size}
/>
))}
{overflow > 0 && (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground"
title={labels
.slice(maxVisible)
.map((l) => l.name)
.join(', ')}
>
+{overflow}
</span>
)}
</div>
)
}
@@ -0,0 +1,392 @@
/**
* TagSelector pannello di selezione/creazione tag per messaggi.
*
* Modalità d'uso:
* - single: apre un Dialog per impostare i tag di un singolo messaggio
* - bulk: apre un Dialog per aggiungere o rimuovere tag da più messaggi
*
* Funzionalità:
* - Lista tutti i tag del tenant con checkbox
* - Ricerca/filtro tag
* - Crea nuovi tag con nome e colore (solo per admin)
* - Mostra anteprima del badge colorato
*/
import { useState, useMemo } from 'react'
import { Plus, Search, Tag, Check, Trash2 } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { TagBadge } from './TagBadge'
import { labelsApi } from '@/api/labels.api'
import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth'
import { cn } from '@/lib/utils'
import type { LabelResponse } from '@/types/api.types'
// ─── Tavolozza colori predefiniti ─────────────────────────────────────────────
const PRESET_COLORS = [
'#ef4444', // rosso
'#f97316', // arancione
'#eab308', // giallo
'#22c55e', // verde
'#14b8a6', // teal
'#3b82f6', // blu
'#8b5cf6', // viola
'#ec4899', // rosa
'#6b7280', // grigio
'#78716c', // marrone
]
// ─── Tipi ──────────────────────────────────────────────────────────────────────
interface TagSelectorSingleProps {
mode: 'single'
open: boolean
onClose: () => void
/** ID dei tag attualmente assegnati al messaggio */
currentLabelIds: string[]
/** Chiamata quando l'utente conferma la selezione */
onApply: (labelIds: string[]) => void
isApplying?: boolean
}
interface TagSelectorBulkProps {
mode: 'bulk'
open: boolean
onClose: () => void
/** Numero di messaggi selezionati (per info nell'UI) */
messageCount: number
/** Chiamata con i label selezionati e l'azione (add/remove) */
onApply: (labelIds: string[], action: 'add' | 'remove') => void
isApplying?: boolean
}
type TagSelectorProps = TagSelectorSingleProps | TagSelectorBulkProps
// ─── Componente principale ────────────────────────────────────────────────────
export function TagSelector(props: TagSelectorProps) {
const { open, onClose, onApply, isApplying } = props
const { user } = useAuth()
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
const queryClient = useQueryClient()
// Stato selezione (per single: IDs scelti; per bulk: IDs da applicare)
const [selectedIds, setSelectedIds] = useState<Set<string>>(
props.mode === 'single' ? new Set(props.currentLabelIds) : new Set(),
)
const [searchQuery, setSearchQuery] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(PRESET_COLORS[5]) // blu default
// Reset dello stato all'apertura
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setSearchQuery('')
setShowCreate(false)
setNewName('')
setNewColor(PRESET_COLORS[5])
if (props.mode === 'bulk') {
setSelectedIds(new Set())
}
onClose()
} else if (props.mode === 'single') {
setSelectedIds(new Set(props.currentLabelIds))
}
}
// Carica lista tag del tenant
const { data: allLabels = [], isLoading } = useQuery({
queryKey: ['labels'],
queryFn: labelsApi.list,
staleTime: 5 * 60 * 1000,
enabled: open,
})
// Tag filtrati per ricerca
const filteredLabels = useMemo(() => {
if (!searchQuery.trim()) return allLabels
const q = searchQuery.toLowerCase()
return allLabels.filter((l) => l.name.toLowerCase().includes(q))
}, [allLabels, searchQuery])
// Crea nuovo tag
const createMutation = useMutation<LabelResponse, Error, void>({
mutationFn: () => labelsApi.create({ name: newName.trim(), color: newColor }),
onSuccess: (newLabel) => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
setSelectedIds((prev) => new Set([...prev, newLabel.id]))
setNewName('')
setShowCreate(false)
toast.success(`Tag "${newLabel.name}" creato`)
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Elimina tag
const deleteMutation = useMutation({
mutationFn: (id: string) => labelsApi.delete(id),
onSuccess: (_, deletedId) => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(deletedId)
return next
})
toast.success('Tag eliminato')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
const toggleLabel = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleApplySingle = () => {
if (props.mode === 'single') {
onApply(Array.from(selectedIds))
}
}
const handleApplyBulk = (action: 'add' | 'remove') => {
if (props.mode === 'bulk') {
onApply(Array.from(selectedIds), action)
}
}
const canConfirm = selectedIds.size > 0
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5 text-primary" />
{props.mode === 'single' ? 'Gestisci tag' : `Assegna tag a ${props.mode === 'bulk' ? props.messageCount : ''} messaggi`}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Ricerca tag */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca tag…"
className="pl-9 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
</div>
{/* Lista tag */}
<div className="max-h-60 overflow-y-auto space-y-1 rounded-lg border bg-muted/20 p-2">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : filteredLabels.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">
{searchQuery ? 'Nessun tag trovato' : 'Nessun tag disponibile'}
</p>
) : (
filteredLabels.map((label) => (
<LabelRow
key={label.id}
label={label}
isSelected={selectedIds.has(label.id)}
onToggle={() => toggleLabel(label.id)}
onDelete={isAdmin ? () => deleteMutation.mutate(label.id) : undefined}
isDeleting={deleteMutation.isPending && deleteMutation.variables === label.id}
/>
))
)}
</div>
{/* Crea nuovo tag (admin only) */}
{isAdmin && (
<div className="border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowCreate(!showCreate)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-muted/50 transition-colors"
>
<Plus className="h-4 w-4 text-primary flex-shrink-0" />
<span className="text-primary font-medium">Crea nuovo tag</span>
</button>
{showCreate && (
<div className="px-3 pb-3 pt-1 border-t bg-muted/10 space-y-3">
<Input
placeholder="Nome tag…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-8 text-sm"
maxLength={100}
onKeyDown={(e) => {
if (e.key === 'Enter' && newName.trim()) createMutation.mutate()
}}
/>
{/* Selettore colore */}
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground">Colore</p>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-6 w-6 rounded-full border-2 transition-all',
newColor === color
? 'border-foreground scale-110'
: 'border-transparent hover:scale-105',
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
{/* Anteprima badge */}
{newName.trim() && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Anteprima:</span>
<TagBadge
label={{ id: 'preview', tenant_id: '', name: newName.trim(), color: newColor }}
size="sm"
/>
</div>
)}
<Button
size="sm"
className="w-full h-8"
disabled={!newName.trim()}
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
>
Crea tag
</Button>
</div>
)}
</div>
)}
</div>
{/* Footer azioni */}
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => handleOpenChange(false)} className="sm:order-first">
Annulla
</Button>
{props.mode === 'single' ? (
<Button
onClick={handleApplySingle}
isLoading={isApplying}
>
Applica tag
</Button>
) : (
<div className="flex gap-2 flex-1 sm:justify-end">
<Button
variant="outline"
disabled={!canConfirm}
onClick={() => handleApplyBulk('remove')}
isLoading={isApplying}
className="flex-1 sm:flex-none"
>
Rimuovi
</Button>
<Button
disabled={!canConfirm}
onClick={() => handleApplyBulk('add')}
isLoading={isApplying}
className="flex-1 sm:flex-none"
>
Aggiungi
</Button>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ─── Riga singolo label nella lista ──────────────────────────────────────────
interface LabelRowProps {
label: LabelResponse
isSelected: boolean
onToggle: () => void
onDelete?: () => void
isDeleting?: boolean
}
function LabelRow({ label, isSelected, onToggle, onDelete, isDeleting }: LabelRowProps) {
return (
<div
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted/50 transition-colors group',
isSelected && 'bg-primary/5',
)}
onClick={onToggle}
>
{/* Checkbox */}
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/40',
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
{/* Badge con colore */}
<TagBadge label={label} size="sm" className="pointer-events-none" />
{/* Spacer */}
<div className="flex-1" />
{/* Pulsante elimina (solo admin, visibile su hover) */}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className={cn(
'p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all',
isDeleting ? 'opacity-50 pointer-events-none' : 'opacity-0 group-hover:opacity-100',
)}
title="Elimina tag"
aria-label={`Elimina tag ${label.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
+76
View File
@@ -82,6 +82,82 @@
animation: spin 1s linear infinite;
}
/* ─── Rich Text Editor (TipTap / ProseMirror) ─────────────────────────────── */
.rte-content {
outline: none;
min-height: inherit;
line-height: 1.6;
font-size: 0.875rem;
}
/* Placeholder */
.rte-content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
float: left;
height: 0;
pointer-events: none;
}
/* Tipografia */
.rte-content h1 { font-size: 1.6rem; font-weight: 700; margin: 0.6rem 0 0.3rem; line-height: 1.3; }
.rte-content h2 { font-size: 1.3rem; font-weight: 600; margin: 0.5rem 0 0.25rem; line-height: 1.3; }
.rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.4rem 0 0.2rem; line-height: 1.3; }
.rte-content p { margin: 0.2rem 0; }
.rte-content p + p { margin-top: 0.4rem; }
/* Elenchi */
.rte-content ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.3rem 0; }
.rte-content ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.3rem 0; }
.rte-content li { margin: 0.15rem 0; }
/* Citazione */
.rte-content blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 0.85rem;
color: hsl(var(--muted-foreground));
margin: 0.4rem 0;
font-style: italic;
}
/* Link */
.rte-content a.rte-link,
.rte-content a {
color: hsl(var(--primary));
text-decoration: underline;
cursor: pointer;
}
.rte-content a:hover { opacity: 0.8; }
/* Codice */
.rte-content code {
background: hsl(var(--muted));
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-family: ui-monospace, monospace;
font-size: 0.8em;
}
.rte-content pre {
background: hsl(222 47% 11%);
color: hsl(210 40% 96%);
padding: 0.75rem 1rem;
border-radius: 0.375rem;
overflow-x: auto;
margin: 0.4rem 0;
}
.rte-content pre code { background: none; color: inherit; padding: 0; }
/* Separatore orizzontale */
.rte-content hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 0.6rem 0;
}
/* Selezione testo */
.rte-content .ProseMirror-selectednode { outline: 2px solid hsl(var(--primary)); }
/* Badge PEC stati */
.pec-badge-draft { @apply bg-gray-100 text-gray-700 border-gray-200; }
.pec-badge-queued { @apply bg-yellow-100 text-yellow-700 border-yellow-200; }
+197 -57
View File
@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useForm, useFieldArray } from 'react-hook-form'
import { Send, X, Plus, ArrowLeft, AlertCircle } from 'lucide-react'
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload } from 'lucide-react'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { getErrorMessage } from '@/api/client'
@@ -17,7 +18,22 @@ interface ComposeFormValues {
to_addresses: { value: string }[]
cc_addresses: { value: string }[]
subject: string
body_text: string
}
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20 MB
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** Estrae testo semplice dall'HTML del rich editor per la parte text/plain dell'email */
function htmlToText(html: string): string {
if (!html || html === '<p></p>') return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
export function ComposePage() {
@@ -25,6 +41,27 @@ export function ComposePage() {
const location = useLocation()
const replyTo = location.state?.replyTo as MessageResponse | undefined
const [showCc, setShowCc] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Corpo HTML (gestito da TipTap)
const [bodyHtml, setBodyHtml] = useState<string>(() => {
if (!replyTo) return ''
const date = new Date(
replyTo.received_at || replyTo.created_at
).toLocaleDateString('it-IT')
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
`<p>Da: ${replyTo.from_address || ''}</p>`,
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
].join('')
})
// Allegati
const [attachments, setAttachments] = useState<File[]>([])
const {
register,
@@ -34,12 +71,11 @@ export function ComposePage() {
} = useForm<ComposeFormValues>({
defaultValues: {
mailbox_id: replyTo?.mailbox_id || '',
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }],
to_addresses: replyTo
? [{ value: replyTo.from_address || '' }]
: [{ value: '' }],
cc_addresses: [],
subject: replyTo ? `Re: ${replyTo.subject || ''}` : '',
body_text: replyTo
? `\n\n---\nIn risposta al messaggio del ${new Date(replyTo.received_at || replyTo.created_at).toLocaleDateString('it-IT')}\nDa: ${replyTo.from_address || ''}\nA: ${replyTo.to_addresses?.join(', ') || ''}\nOggetto: ${replyTo.subject || ''}`
: '',
},
})
@@ -62,7 +98,10 @@ export function ComposePage() {
})
const sendMutation = useMutation({
mutationFn: sendApi.send,
mutationFn: (args: {
data: Parameters<typeof sendApi.sendMultipart>[0]
files: File[]
}) => sendApi.sendMultipart(args.data, args.files),
onSuccess: (job) => {
toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`)
navigate('/sent')
@@ -72,8 +111,29 @@ export function ComposePage() {
},
})
const onSubmit = async (data: ComposeFormValues) => {
const toAddresses = data.to_addresses
// ── Gestione allegati ──────────────────────────────────────────────────────
const handleFileAdd = (files: FileList | null) => {
if (!files) return
const valid: File[] = []
for (const file of Array.from(files)) {
if (file.size > MAX_FILE_SIZE) {
toast.error(`"${file.name}" supera il limite di 20 MB`)
} else {
valid.push(file)
}
}
if (valid.length) setAttachments((prev) => [...prev, ...valid])
}
const removeAttachment = (idx: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== idx))
}
// ── Submit ─────────────────────────────────────────────────────────────────
const onSubmit = async (formData: ComposeFormValues) => {
const toAddresses = formData.to_addresses
.map((t) => t.value.trim())
.filter((v) => v.length > 0)
@@ -83,14 +143,18 @@ export function ComposePage() {
}
await sendMutation.mutateAsync({
mailbox_id: data.mailbox_id,
to_addresses: toAddresses,
cc_addresses: data.cc_addresses
.map((c) => c.value.trim())
.filter((v) => v.length > 0),
subject: data.subject,
body_text: data.body_text,
reply_to_message_id: replyTo?.id,
data: {
mailbox_id: formData.mailbox_id,
to_addresses: toAddresses,
cc_addresses: formData.cc_addresses
.map((c) => c.value.trim())
.filter((v) => v.length > 0),
subject: formData.subject,
body_text: htmlToText(bodyHtml),
body_html: bodyHtml,
reply_to_message_id: replyTo?.id,
},
files: attachments,
})
}
@@ -99,28 +163,29 @@ export function ComposePage() {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
</h1>
{replyTo && (
<p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject}
</p>
)}
</div>
<div className="border-b bg-background px-6 py-4 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
</h1>
{replyTo && (
<p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject}
</p>
)}
</div>
</div>
{/* Form */}
<div className="flex-1 overflow-y-auto">
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl mx-auto px-6 py-8 space-y-6">
{/* Avviso */}
<form
onSubmit={handleSubmit(onSubmit)}
className="max-w-4xl mx-auto px-6 py-6 space-y-5"
>
{/* Avviso informativo */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700">
@@ -130,7 +195,7 @@ export function ComposePage() {
</div>
{/* Casella mittente */}
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor="mailbox_id">Casella mittente *</Label>
{mailboxesLoading ? (
<div className="h-10 rounded-md border bg-muted animate-pulse" />
@@ -141,8 +206,10 @@ export function ComposePage() {
) : (
<select
id="mailbox_id"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...register('mailbox_id', { required: 'Seleziona una casella mittente' })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
{...register('mailbox_id', {
required: 'Seleziona una casella mittente',
})}
>
<option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => (
@@ -158,7 +225,7 @@ export function ComposePage() {
</div>
{/* Destinatari A: */}
<div className="space-y-2">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Destinatari (A:) *</Label>
<Button
@@ -178,7 +245,8 @@ export function ComposePage() {
type="email"
placeholder="destinatario@pec.it"
{...register(`to_addresses.${idx}.value`, {
required: idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
required:
idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
})}
/>
{toFields.length > 1 && (
@@ -202,7 +270,7 @@ export function ComposePage() {
</div>
{/* CC */}
<div className="space-y-2">
<div className="space-y-1.5">
{!showCc ? (
<button
type="button"
@@ -247,7 +315,7 @@ export function ComposePage() {
</div>
{/* Oggetto */}
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor="subject">Oggetto *</Label>
<Input
id="subject"
@@ -259,25 +327,92 @@ export function ComposePage() {
)}
</div>
{/* Corpo */}
<div className="space-y-2">
<Label htmlFor="body_text">Testo del messaggio</Label>
<textarea
id="body_text"
rows={12}
placeholder="Testo della PEC..."
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
{...register('body_text')}
{/* Corpo Rich Text Editor */}
<div className="space-y-1.5">
<Label>Testo del messaggio</Label>
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
placeholder="Scrivi il testo della PEC..."
minHeight="280px"
/>
</div>
{/* Azioni */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
{/* ── Allegati ────────────────────────────────────────────────── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Allegati</Label>
<span className="text-xs text-muted-foreground">Max 20 MB per file</span>
</div>
{/* Lista allegati caricati */}
{attachments.length > 0 && (
<div className="space-y-1.5">
{attachments.map((file, idx) => (
<div
key={`${file.name}-${file.size}-${idx}`}
className="flex items-center gap-2.5 px-3 py-2 bg-muted/50 border rounded-md"
>
<Paperclip className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span
className="text-sm flex-1 truncate font-medium"
title={file.name}
>
{file.name}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatFileSize(file.size)}
</span>
<button
type="button"
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => removeAttachment(idx)}
title="Rimuovi allegato"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{/* Zona drag-and-drop / click per aggiungere file */}
<div
className="border-2 border-dashed rounded-md p-5 flex flex-col items-center gap-2 cursor-pointer hover:border-primary/60 hover:bg-primary/5 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={(e) => {
e.preventDefault()
handleFileAdd(e.dataTransfer.files)
}}
>
<Upload className="h-7 w-7 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium text-foreground">
Clicca o trascina i file qui
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Qualsiasi tipo di file Max 20 MB per file
</p>
</div>
</div>
{/* Input file nascosto */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileAdd(e.target.files)}
/>
</div>
{/* ── Azioni ──────────────────────────────────────────────────── */}
<div className="flex items-center justify-between pt-4 border-t">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Annulla
</Button>
<Button
@@ -287,6 +422,11 @@ export function ComposePage() {
>
<Send className="h-4 w-4 mr-2" />
Invia PEC
{attachments.length > 0 && (
<span className="ml-1.5 bg-primary-foreground/20 text-primary-foreground rounded-full px-1.5 text-xs font-semibold">
+{attachments.length}
</span>
)}
</Button>
</div>
</form>
+64 -2
View File
@@ -9,9 +9,9 @@
*
* Funzionalità:
* - Selezione singola e multipla tramite checkbox
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio)
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag)
* - Pulsanti azione rapida su hover di ogni riga (stella, archivia)
* - Tutte le azioni funzionano anche in senso inverso (unstar / unarchive)
* - Badge tag colorati per ogni messaggio
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
@@ -31,13 +31,17 @@ import {
CheckSquare,
Square,
X,
Tag,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { formatRelative, truncate } from '@/lib/utils'
@@ -75,6 +79,9 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// ── Stato dialog tag bulk ────────────────────────────────────────────────────
const [showTagSelector, setShowTagSelector] = useState(false)
// Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
useEffect(() => {
setSearchInput('')
@@ -248,6 +255,31 @@ export function InboxPage({ viewMode }: InboxPageProps) {
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Bulk tag ─────────────────────────────────────────────────────────────────
const bulkLabelMutation = useMutation({
mutationFn: labelsApi.bulkLabels,
onSuccess: (result, payload) => {
invalidateMessages()
setSelectedIds(new Set())
setShowTagSelector(false)
const n = result.updated
if (payload.action === 'add') {
toast.success(`Tag aggiunti a ${n} ${n === 1 ? 'messaggio' : 'messaggi'}`)
} else {
toast.success(`Tag rimossi da ${n} ${n === 1 ? 'messaggio' : 'messaggi'}`)
}
},
onError: (error) => toast.error(getErrorMessage(error)),
})
const handleBulkTag = (labelIds: string[], action: 'add' | 'remove') => {
bulkLabelMutation.mutate({
message_ids: Array.from(selectedIds),
label_ids: labelIds,
action,
})
}
const handleBulkStar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
const handleBulkUnstar = () =>
@@ -495,6 +527,17 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Ripristina dalla posta
</Button>
)}
{/* Assegna tag bulk */}
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={() => setShowTagSelector(true)}
>
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
Tag
</Button>
</div>
)}
@@ -599,6 +642,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</div>
</div>
)}
{/* ── Dialog assegnazione tag bulk ── */}
{showTagSelector && (
<TagSelector
mode="bulk"
open={showTagSelector}
onClose={() => setShowTagSelector(false)}
messageCount={selectedCount}
onApply={handleBulkTag}
isApplying={bulkLabelMutation.isPending}
/>
)}
</div>
)
}
@@ -715,6 +770,13 @@ function MessageRow({
</p>
</div>
{/* Tag badges */}
{message.labels && message.labels.length > 0 && (
<div className="mt-1" onClick={(e) => e.stopPropagation()}>
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
</div>
)}
{message.body_text && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{truncate(message.body_text, 120)}
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
@@ -10,12 +11,16 @@ import {
Paperclip,
Mail,
Send,
Tag,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { TagBadge } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
@@ -24,6 +29,9 @@ export function MessageDetailPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
// Dialog tag
const [showTagSelector, setShowTagSelector] = useState(false)
// Carica messaggio
const {
data: message,
@@ -72,6 +80,15 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
// Download allegato autenticato
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
try {
await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename)
} catch (error) {
toast.error(getErrorMessage(error))
}
}
// Ripristina dall'archivio
const unarchiveMutation = useMutation({
mutationFn: () => messagesApi.unarchive(id!),
@@ -83,6 +100,38 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
// Imposta tag del messaggio
const setLabelsMutation = useMutation({
mutationFn: (labelIds: string[]) =>
labelsApi.setMessageLabels(id!, { label_ids: labelIds }),
onSuccess: (updatedLabels) => {
// Aggiorna la cache del messaggio con i nuovi label
queryClient.setQueryData(['message', id], (old: typeof message) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
// Invalida la lista messaggi per aggiornare i badge nella inbox
queryClient.invalidateQueries({ queryKey: ['messages'] })
setShowTagSelector(false)
toast.success('Tag aggiornati')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Rimuove singolo tag (click su × nel badge)
const removeLabelMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }),
onSuccess: (updatedLabels) => {
queryClient.setQueryData(['message', id], (old: typeof message) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
queryClient.invalidateQueries({ queryKey: ['messages'] })
},
onError: (error) => toast.error(getErrorMessage(error)),
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -103,6 +152,8 @@ export function MessageDetailPage() {
)
}
const currentLabelIds = (message.labels || []).map((l) => l.id)
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
@@ -117,6 +168,22 @@ export function MessageDetailPage() {
</div>
<div className="flex items-center gap-2">
{/* Gestisci tag */}
<Button
variant="outline"
size="sm"
onClick={() => setShowTagSelector(true)}
title="Gestisci tag"
>
<Tag className="h-4 w-4 mr-1 text-primary" />
Tag
{message.labels && message.labels.length > 0 && (
<span className="ml-1 text-xs bg-primary text-primary-foreground rounded-full px-1.5 py-0.5">
{message.labels.length}
</span>
)}
</Button>
{/* Stella / Preferito */}
<Button
variant="ghost"
@@ -212,6 +279,28 @@ export function MessageDetailPage() {
</div>
</div>
{/* Tag badges */}
{message.labels && message.labels.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{message.labels.map((label) => (
<TagBadge
key={label.id}
label={label}
size="md"
onRemove={() => removeLabelMutation.mutate(label.id)}
/>
))}
<button
type="button"
onClick={() => setShowTagSelector(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
>
<Tag className="h-3.5 w-3.5" />
Modifica
</button>
</div>
)}
{/* Dettagli busta */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="grid grid-cols-[auto,1fr] gap-x-4 gap-y-1.5 text-sm">
@@ -291,11 +380,11 @@ export function MessageDetailPage() {
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((att) => (
<a
<button
key={att.id}
href={messagesApi.getAttachmentUrl(message.id, att.id)}
download={att.filename}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group"
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group text-left w-full"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
@@ -307,7 +396,7 @@ export function MessageDetailPage() {
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
</a>
</button>
))}
</div>
</div>
@@ -342,6 +431,18 @@ export function MessageDetailPage() {
)}
</div>
</div>
{/* Dialog gestione tag */}
{showTagSelector && (
<TagSelector
mode="single"
open={showTagSelector}
onClose={() => setShowTagSelector(false)}
currentLabelIds={currentLabelIds}
onApply={(labelIds) => setLabelsMutation.mutate(labelIds)}
isApplying={setLabelsMutation.isPending}
/>
)}
</div>
)
}
+42
View File
@@ -133,6 +133,47 @@ export interface ConnectionTestResult {
capabilities: string[] | null
}
// ─── Label (Tag) ──────────────────────────────────────────────────────────────
export interface LabelResponse {
id: string
tenant_id: string
name: string
color: string | null
}
export interface LabelCreate {
name: string
color?: string | null
}
export interface LabelUpdate {
name?: string
color?: string | null
}
export interface MessageLabelSetRequest {
label_ids: string[]
}
export interface MessageLabelAddRequest {
label_ids: string[]
}
export interface MessageLabelRemoveRequest {
label_ids: string[]
}
export interface MessageBulkLabelRequest {
message_ids: string[]
label_ids: string[]
action: 'add' | 'remove'
}
export interface MessageBulkLabelResponse {
updated: number
}
// ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound'
@@ -185,6 +226,7 @@ export interface MessageResponse {
raw_eml_path: string | null
created_at: string
updated_at: string
labels: LabelResponse[]
}
export interface MessageListResponse {
+21 -9
View File
@@ -5,11 +5,15 @@ server {
# Redirect HTTP → HTTPS in produzione (commentato per dev)
# return 301 https://$host$request_uri;
# Resolver Docker interno re-risolve i nomi dei container ogni 30s
resolver 127.0.0.11 valid=30s ipv6=off;
# ── API Backend ───────────────────────────────────────────────────────────
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -30,7 +34,8 @@ server {
location /api/v1/auth/login {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -40,24 +45,29 @@ server {
# ── Health check ──────────────────────────────────────────────────────────
location /health {
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
access_log off;
}
# ── Swagger UI (solo dev) ─────────────────────────────────────────────────
location /docs {
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
}
location /redoc {
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
}
location /openapi.json {
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
}
# ── WebSocket ─────────────────────────────────────────────────────────────
location /ws/ {
proxy_pass http://backend:8000;
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -67,7 +77,8 @@ server {
# ── Frontend React (Vite dev server) ─────────────────────────────────────
location / {
proxy_pass http://frontend:3000;
set $frontend_upstream http://frontend:3000;
proxy_pass $frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -80,7 +91,8 @@ server {
# ── Vite HMR WebSocket ────────────────────────────────────────────────────
location /@vite/ {
proxy_pass http://frontend:3000;
set $frontend_upstream http://frontend:3000;
proxy_pass $frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
+33 -3
View File
@@ -29,9 +29,9 @@ from typing import Any
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Mailbox, Message, SendJob
from app.models import Attachment, Mailbox, Message, SendJob
from app.smtp.sender import SmtpSender
from app.storage.minio_client import upload_outbound_eml
from app.storage.minio_client import download_attachment, upload_outbound_eml
logger = logging.getLogger(__name__)
@@ -105,6 +105,36 @@ async def send_pec(ctx: dict[str, Any], send_job_id: str) -> dict:
f"per job {send_job_id}{msg.to_addresses}"
)
# ── Carica allegati da MinIO (se presenti) ────────────────────────────
attachments_data: list[dict] | None = None
if msg.has_attachments:
att_result = await db.execute(
select(Attachment).where(Attachment.message_id == msg.id)
)
att_records = list(att_result.scalars().all())
if att_records:
attachments_data = []
for att in att_records:
try:
content = await download_attachment(att.storage_path)
attachments_data.append(
{
"filename": att.filename,
"content": content,
"content_type": att.content_type or "application/octet-stream",
}
)
logger.debug(
f"[send_pec] Allegato caricato per invio: "
f"{att.filename} ({len(content)} bytes)"
)
except Exception as att_err:
logger.warning(
f"[send_pec] Impossibile caricare allegato "
f"'{att.filename}' ({att.storage_path}): {att_err}"
)
# Continua senza questo allegato
# ── Tenta invio SMTP ──────────────────────────────────────────────────
try:
sender = SmtpSender(mailbox)
@@ -114,7 +144,7 @@ async def send_pec(ctx: dict[str, Any], send_job_id: str) -> dict:
subject=msg.subject or "",
body_text=msg.body_text or "",
body_html=msg.body_html,
attachments=None, # allegati in fase successiva (Fase 5)
attachments=attachments_data,
)
# ── Successo: aggiorna DB ─────────────────────────────────────────
+33
View File
@@ -180,6 +180,39 @@ async def upload_outbound_eml(
raise
async def download_attachment(storage_path: str) -> bytes:
"""
Scarica un allegato da MinIO e restituisce i byte.
Args:
storage_path: percorso oggetto MinIO (senza bucket name)
Returns:
Byte del file scaricato
Raises:
Exception: se il download fallisce
"""
client = get_minio_client()
bucket = settings.minio_bucket
try:
response = await client.get_object(
bucket_name=bucket,
object_name=storage_path,
)
data = await response.read()
response.close()
await response.release()
logger.debug(
f"Allegato scaricato: s3://{bucket}/{storage_path} ({len(data)} bytes)"
)
return data
except Exception as e:
logger.error(f"Errore download allegato {storage_path}: {e}")
raise
async def ensure_bucket_exists() -> None:
"""Verifica che il bucket MinIO esista, altrimenti lo crea."""
client = get_minio_client()