feat: mail phase 2 - delete, move, drafts
- Add deleted_at field to Mail/MailAccount/MailFolder models
- Add DELETE /{mail_id} endpoint (soft-delete + IMAP EXPUNGE)
- Add POST /{mail_id}/move endpoint (IMAP UID MOVE + folder_id update)
- Add POST /drafts and PUT /drafts/{id} endpoints (save/edit drafts)
- Add imap_delete_mail() and imap_move_mail() service functions
- Add save_draft() and update_draft() service functions
- Frontend: deleteMail, moveMail, saveDraft, updateDraft API functions
- ComposeModal: draft mode with Save Draft button
- MailDetail: delete/move buttons, edit-draft for drafts
- Mail.tsx: bulk delete/move, move dropdown, draft handlers
- i18n: 13 new keys (de/en)
This commit is contained in:
@@ -48,6 +48,7 @@ class MailAccount(Base, TenantMixin):
|
|||||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# --- Mail Folders (F-MAIL-01, F-MAIL-19) ---
|
# --- Mail Folders (F-MAIL-01, F-MAIL-19) ---
|
||||||
@@ -80,6 +81,7 @@ class MailFolder(Base, TenantMixin):
|
|||||||
is_standard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_standard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
unread_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
unread_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# --- Mails (F-MAIL-01, F-MAIL-03, F-MAIL-05) ---
|
# --- Mails (F-MAIL-01, F-MAIL-03, F-MAIL-05) ---
|
||||||
@@ -132,6 +134,7 @@ class Mail(Base, TenantMixin):
|
|||||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||||
company_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
company_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# --- Mail Attachments (F-MAIL-04) ---
|
# --- Mail Attachments (F-MAIL-04) ---
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from app.plugins.builtins.mail.schemas import (
|
|||||||
MailAccountCreate,
|
MailAccountCreate,
|
||||||
MailAccountUpdate,
|
MailAccountUpdate,
|
||||||
MailCreateEventRequest,
|
MailCreateEventRequest,
|
||||||
|
MailDraftCreate,
|
||||||
MailFlagsUpdate,
|
MailFlagsUpdate,
|
||||||
MailFolderCreate,
|
MailFolderCreate,
|
||||||
MailFolderUpdate,
|
MailFolderUpdate,
|
||||||
@@ -46,6 +47,7 @@ from app.plugins.builtins.mail.schemas import (
|
|||||||
MailLabelAssign,
|
MailLabelAssign,
|
||||||
MailLabelCreate,
|
MailLabelCreate,
|
||||||
MailLinkRequest,
|
MailLinkRequest,
|
||||||
|
MailMoveRequest,
|
||||||
MailReplyRequest,
|
MailReplyRequest,
|
||||||
MailRuleCreate,
|
MailRuleCreate,
|
||||||
MailSendRequest,
|
MailSendRequest,
|
||||||
@@ -71,6 +73,8 @@ from app.plugins.builtins.mail.services import (
|
|||||||
folder_to_response,
|
folder_to_response,
|
||||||
forward_mail,
|
forward_mail,
|
||||||
get_account_password,
|
get_account_password,
|
||||||
|
imap_delete_mail,
|
||||||
|
imap_move_mail,
|
||||||
imap_sync_account,
|
imap_sync_account,
|
||||||
import_pgp_private_key,
|
import_pgp_private_key,
|
||||||
import_pgp_public_key,
|
import_pgp_public_key,
|
||||||
@@ -80,11 +84,13 @@ from app.plugins.builtins.mail.services import (
|
|||||||
pgp_encrypt_message,
|
pgp_encrypt_message,
|
||||||
reply_to_mail,
|
reply_to_mail,
|
||||||
rule_to_response,
|
rule_to_response,
|
||||||
|
save_draft,
|
||||||
send_mail_via_smtp,
|
send_mail_via_smtp,
|
||||||
should_send_vacation_reply,
|
should_send_vacation_reply,
|
||||||
signature_to_response,
|
signature_to_response,
|
||||||
substitute_template_vars,
|
substitute_template_vars,
|
||||||
template_to_response,
|
template_to_response,
|
||||||
|
update_draft,
|
||||||
update_mail_account,
|
update_mail_account,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1377,6 +1383,100 @@ async def assign_label(
|
|||||||
return {"assigned": True, "label_id": str(l_id)}
|
return {"assigned": True, "label_id": str(l_id)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Drafts (F-MAIL-15) ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/drafts", status_code=201)
|
||||||
|
async def create_draft(
|
||||||
|
data: MailDraftCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
acc_id = _parse_uuid(data.account_id, "account_id")
|
||||||
|
await _get_account(db, acc_id, tenant_id, user_id)
|
||||||
|
mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump())
|
||||||
|
return mail_to_response(mail)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/drafts/{mail_id}")
|
||||||
|
async def update_draft_route(
|
||||||
|
mail_id: str,
|
||||||
|
data: MailDraftCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
m_id = _parse_uuid(mail_id, "mail_id")
|
||||||
|
mail = await update_draft(db, m_id, tenant_id, data.model_dump())
|
||||||
|
return mail_to_response(mail)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Delete / Move (dynamic /{mail_id} routes) ───
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{mail_id}", status_code=204)
|
||||||
|
async def delete_mail(
|
||||||
|
mail_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
m_id = _parse_uuid(mail_id, "mail_id")
|
||||||
|
mail = (
|
||||||
|
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not mail:
|
||||||
|
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
|
||||||
|
# Soft-delete: set deleted_at = now()
|
||||||
|
mail.deleted_at = datetime.now(UTC)
|
||||||
|
await db.flush()
|
||||||
|
# IMAP delete: non-critical, errors logged but DB soft-delete still succeeds
|
||||||
|
try:
|
||||||
|
await mail_services.imap_delete_mail(db, m_id, tenant_id)
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("IMAP delete failed for mail %s: %s", m_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{mail_id}/move")
|
||||||
|
async def move_mail(
|
||||||
|
mail_id: str,
|
||||||
|
data: MailMoveRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
m_id = _parse_uuid(mail_id, "mail_id")
|
||||||
|
target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id")
|
||||||
|
mail = (
|
||||||
|
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not mail:
|
||||||
|
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
|
||||||
|
# Verify target folder exists and belongs to same tenant
|
||||||
|
target_folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(MailFolder.id == target_f_id, MailFolder.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not target_folder:
|
||||||
|
raise HTTPException(404, detail={"detail": "Target folder not found", "code": "not_found"})
|
||||||
|
# Update mail.folder_id
|
||||||
|
mail.folder_id = target_f_id
|
||||||
|
await db.flush()
|
||||||
|
# IMAP move: non-critical
|
||||||
|
try:
|
||||||
|
await mail_services.imap_move_mail(db, m_id, target_f_id, tenant_id)
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("IMAP move failed for mail %s: %s", m_id, exc)
|
||||||
|
return mail_to_response(mail)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# DYNAMIC ROUTES — registered LAST so static routes are not shadowed
|
# DYNAMIC ROUTES — registered LAST so static routes are not shadowed
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ class MailLinkRequest(BaseModel):
|
|||||||
company_id: str | None = None
|
company_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MailMoveRequest(BaseModel):
|
||||||
|
target_folder_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class MailDraftCreate(BaseModel):
|
||||||
|
account_id: str
|
||||||
|
to: list[str] = Field(default_factory=list)
|
||||||
|
cc: list[str] = Field(default_factory=list)
|
||||||
|
bcc: list[str] = Field(default_factory=list)
|
||||||
|
subject: str = ""
|
||||||
|
body_text: str = ""
|
||||||
|
body_html: str = ""
|
||||||
|
|
||||||
|
|
||||||
class MailCreateEventRequest(BaseModel):
|
class MailCreateEventRequest(BaseModel):
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
|||||||
@@ -1403,3 +1403,466 @@ async def imap_sync_mail_flags(
|
|||||||
await client.logout()
|
await client.logout()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IMAP Delete ───
|
||||||
|
|
||||||
|
|
||||||
|
async def imap_delete_mail(
|
||||||
|
db: AsyncSession, mail_id: uuid.UUID, tenant_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
"""Delete mail from IMAP server.
|
||||||
|
|
||||||
|
Connects to IMAP, selects the mail's folder, finds UID by Message-ID,
|
||||||
|
marks it as \\Deleted and expunges.
|
||||||
|
Non-critical: errors are logged, DB soft-delete still succeeds.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mail = (
|
||||||
|
await db.execute(
|
||||||
|
select(Mail).where(
|
||||||
|
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not mail:
|
||||||
|
logger.warning("imap_delete_mail: mail %s not found", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not folder:
|
||||||
|
logger.warning("imap_delete_mail: folder %s not found for mail %s", mail.folder_id, mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
account = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailAccount).where(
|
||||||
|
and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
logger.warning("imap_delete_mail: account not found for mail %s", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not mail.message_id:
|
||||||
|
logger.warning("imap_delete_mail: mail %s has no message_id, cannot sync", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
password = await get_account_password(account)
|
||||||
|
client = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||||
|
await client.wait_hello_from_server()
|
||||||
|
await client.login(account.username, password)
|
||||||
|
|
||||||
|
select_resp = await client.select(folder.imap_name)
|
||||||
|
if select_resp.result != 'OK':
|
||||||
|
logger.warning("imap_delete_mail: cannot select folder %s", folder.imap_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
|
||||||
|
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
|
||||||
|
if isinstance(uids_raw, (bytes, bytearray)):
|
||||||
|
uids = uids_raw.split()
|
||||||
|
else:
|
||||||
|
uids = []
|
||||||
|
|
||||||
|
if not uids:
|
||||||
|
logger.warning("imap_delete_mail: no UID found for Message-ID %s", mail.message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
|
||||||
|
|
||||||
|
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
|
||||||
|
await client.expunge()
|
||||||
|
|
||||||
|
logger.info("imap_delete_mail: deleted mail %s (UID %s) from IMAP", mail_id, uid_str)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc)
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
await client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IMAP Move ───
|
||||||
|
|
||||||
|
|
||||||
|
async def imap_move_mail(
|
||||||
|
db: AsyncSession,
|
||||||
|
mail_id: uuid.UUID,
|
||||||
|
target_folder_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Move mail to another folder on IMAP server.
|
||||||
|
|
||||||
|
Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE.
|
||||||
|
Non-critical: errors are logged, DB update still succeeds.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mail = (
|
||||||
|
await db.execute(
|
||||||
|
select(Mail).where(
|
||||||
|
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not mail:
|
||||||
|
logger.warning("imap_move_mail: mail %s not found", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
source_folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not source_folder:
|
||||||
|
logger.warning("imap_move_mail: source folder not found for mail %s", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(MailFolder.id == target_folder_id, MailFolder.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not target_folder:
|
||||||
|
logger.warning("imap_move_mail: target folder %s not found", target_folder_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
account = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailAccount).where(
|
||||||
|
and_(MailAccount.id == source_folder.account_id, MailAccount.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
logger.warning("imap_move_mail: account not found for mail %s", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not mail.message_id:
|
||||||
|
logger.warning("imap_move_mail: mail %s has no message_id, cannot sync", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
password = await get_account_password(account)
|
||||||
|
client = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||||
|
await client.wait_hello_from_server()
|
||||||
|
await client.login(account.username, password)
|
||||||
|
|
||||||
|
select_resp = await client.select(source_folder.imap_name)
|
||||||
|
if select_resp.result != 'OK':
|
||||||
|
logger.warning("imap_move_mail: cannot select folder %s", source_folder.imap_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
|
||||||
|
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
|
||||||
|
if isinstance(uids_raw, (bytes, bytearray)):
|
||||||
|
uids = uids_raw.split()
|
||||||
|
else:
|
||||||
|
uids = []
|
||||||
|
|
||||||
|
if not uids:
|
||||||
|
logger.warning("imap_move_mail: no UID found for Message-ID %s", mail.message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
|
||||||
|
|
||||||
|
# Try UID MOVE first; fall back to COPY + STORE \\Deleted + EXPUNGE
|
||||||
|
try:
|
||||||
|
move_resp = await client.uid('move', uid_str, target_folder.imap_name)
|
||||||
|
if move_resp.result == 'OK':
|
||||||
|
logger.info("imap_move_mail: moved mail %s (UID %s) via UID MOVE", mail_id, uid_str)
|
||||||
|
return
|
||||||
|
except Exception as move_exc:
|
||||||
|
logger.info("imap_move_mail: UID MOVE not supported, falling back: %s", move_exc)
|
||||||
|
|
||||||
|
# Fallback: COPY + STORE \\Deleted + EXPUNGE
|
||||||
|
copy_resp = await client.uid('copy', uid_str, target_folder.imap_name)
|
||||||
|
if copy_resp.result != 'OK':
|
||||||
|
logger.warning("imap_move_mail: COPY failed for mail %s", mail_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
|
||||||
|
await client.expunge()
|
||||||
|
|
||||||
|
logger.info("imap_move_mail: moved mail %s (UID %s) via COPY+DELETE", mail_id, uid_str)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("imap_move_mail: failed for mail %s: %s", mail_id, exc)
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
await client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Draft Save / Update ───
|
||||||
|
|
||||||
|
|
||||||
|
async def save_draft(
|
||||||
|
db: AsyncSession,
|
||||||
|
account_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
) -> Mail:
|
||||||
|
"""Save a new draft mail to DB and IMAP Drafts folder."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 1. Find the Drafts folder (imap_name contains 'Drafts')
|
||||||
|
account = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailAccount).where(
|
||||||
|
and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
raise ValueError("Account not found")
|
||||||
|
|
||||||
|
folders = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(MailFolder.account_id == account_id, MailFolder.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
drafts_folder = None
|
||||||
|
for f in folders:
|
||||||
|
if 'draft' in f.imap_name.lower():
|
||||||
|
drafts_folder = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if not drafts_folder:
|
||||||
|
raise ValueError("No Drafts folder found for this account")
|
||||||
|
|
||||||
|
# 2. Create Mail record
|
||||||
|
to_str = ', '.join(data.get('to', []))
|
||||||
|
cc_str = ', '.join(data.get('cc', []))
|
||||||
|
bcc_str = ', '.join(data.get('bcc', []))
|
||||||
|
subject = data.get('subject', '')
|
||||||
|
body_text = data.get('body_text', '')
|
||||||
|
body_html = data.get('body_html', '')
|
||||||
|
|
||||||
|
msg_id = make_msgid()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
mail = Mail(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
folder_id=drafts_folder.id,
|
||||||
|
message_id=msg_id,
|
||||||
|
thread_id=msg_id,
|
||||||
|
subject=subject,
|
||||||
|
from_address=account.email_address,
|
||||||
|
to_addresses=to_str,
|
||||||
|
cc_addresses=cc_str,
|
||||||
|
bcc_addresses=bcc_str,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
body_html_sanitized=body_html,
|
||||||
|
is_seen=True,
|
||||||
|
is_flagged=False,
|
||||||
|
is_draft=True,
|
||||||
|
is_answered=False,
|
||||||
|
is_forwarded=False,
|
||||||
|
has_attachments=False,
|
||||||
|
size_bytes=len(body_text.encode('utf-8')),
|
||||||
|
received_at=now,
|
||||||
|
sent_at=None,
|
||||||
|
)
|
||||||
|
db.add(mail)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 3. Build RFC822 message and APPEND to IMAP Drafts folder
|
||||||
|
password = await get_account_password(account)
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||||
|
await client.wait_hello_from_server()
|
||||||
|
await client.login(account.username, password)
|
||||||
|
|
||||||
|
# Build RFC822 message
|
||||||
|
email_msg = EmailMessage()
|
||||||
|
email_msg['From'] = formataddr((account.display_name, account.email_address))
|
||||||
|
if to_str:
|
||||||
|
email_msg['To'] = to_str
|
||||||
|
if cc_str:
|
||||||
|
email_msg['Cc'] = cc_str
|
||||||
|
email_msg['Subject'] = subject
|
||||||
|
email_msg['Date'] = formatdate(localtime=True)
|
||||||
|
email_msg['Message-ID'] = msg_id
|
||||||
|
email_msg.set_content(body_text if body_text else '')
|
||||||
|
if body_html:
|
||||||
|
email_msg.add_alternative(body_html, subtype='html')
|
||||||
|
|
||||||
|
rfc822_bytes = email_msg.as_bytes()
|
||||||
|
|
||||||
|
# APPEND to Drafts folder
|
||||||
|
append_resp = await client.append(
|
||||||
|
drafts_folder.imap_name,
|
||||||
|
r'(\\Draft)',
|
||||||
|
str(int(now.timestamp())),
|
||||||
|
rfc822_bytes,
|
||||||
|
)
|
||||||
|
if append_resp.result != 'OK':
|
||||||
|
logger.warning("save_draft: IMAP APPEND failed for drafts folder %s", drafts_folder.imap_name)
|
||||||
|
else:
|
||||||
|
logger.info("save_draft: appended draft %s to IMAP Drafts", mail.id)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("save_draft: IMAP append failed (non-critical): %s", exc)
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
await client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return mail
|
||||||
|
|
||||||
|
|
||||||
|
async def update_draft(
|
||||||
|
db: AsyncSession,
|
||||||
|
mail_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
) -> Mail:
|
||||||
|
"""Update an existing draft."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mail = (
|
||||||
|
await db.execute(
|
||||||
|
select(Mail).where(
|
||||||
|
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not mail:
|
||||||
|
raise ValueError("Mail not found")
|
||||||
|
if not mail.is_draft:
|
||||||
|
raise ValueError("Mail is not a draft")
|
||||||
|
|
||||||
|
# 2. Update fields
|
||||||
|
to_str = ', '.join(data.get('to', []))
|
||||||
|
cc_str = ', '.join(data.get('cc', []))
|
||||||
|
bcc_str = ', '.join(data.get('bcc', []))
|
||||||
|
mail.to_addresses = to_str
|
||||||
|
mail.cc_addresses = cc_str
|
||||||
|
mail.bcc_addresses = bcc_str
|
||||||
|
mail.subject = data.get('subject', '')
|
||||||
|
mail.body_text = data.get('body_text', '')
|
||||||
|
mail.body_html = data.get('body_html', '')
|
||||||
|
mail.body_html_sanitized = data.get('body_html', '')
|
||||||
|
mail.size_bytes = len(mail.body_text.encode('utf-8'))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 3. Delete old IMAP copy and APPEND new one
|
||||||
|
folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not folder:
|
||||||
|
return mail
|
||||||
|
|
||||||
|
account = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailAccount).where(
|
||||||
|
and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
return mail
|
||||||
|
|
||||||
|
password = await get_account_password(account)
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||||
|
await client.wait_hello_from_server()
|
||||||
|
await client.login(account.username, password)
|
||||||
|
|
||||||
|
# Delete old copy from IMAP
|
||||||
|
select_resp = await client.select(folder.imap_name)
|
||||||
|
if select_resp.result == 'OK' and mail.message_id:
|
||||||
|
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
|
||||||
|
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
|
||||||
|
if isinstance(uids_raw, (bytes, bytearray)):
|
||||||
|
uids = uids_raw.split()
|
||||||
|
else:
|
||||||
|
uids = []
|
||||||
|
if uids:
|
||||||
|
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
|
||||||
|
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
|
||||||
|
await client.expunge()
|
||||||
|
|
||||||
|
# APPEND new copy
|
||||||
|
email_msg = EmailMessage()
|
||||||
|
email_msg['From'] = formataddr((account.display_name, account.email_address))
|
||||||
|
if to_str:
|
||||||
|
email_msg['To'] = to_str
|
||||||
|
if cc_str:
|
||||||
|
email_msg['Cc'] = cc_str
|
||||||
|
email_msg['Subject'] = mail.subject
|
||||||
|
email_msg['Date'] = formatdate(localtime=True)
|
||||||
|
email_msg['Message-ID'] = mail.message_id
|
||||||
|
email_msg.set_content(mail.body_text if mail.body_text else '')
|
||||||
|
if mail.body_html:
|
||||||
|
email_msg.add_alternative(mail.body_html, subtype='html')
|
||||||
|
|
||||||
|
rfc822_bytes = email_msg.as_bytes()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
append_resp = await client.append(
|
||||||
|
folder.imap_name,
|
||||||
|
r'(\\Draft)',
|
||||||
|
str(int(now.timestamp())),
|
||||||
|
rfc822_bytes,
|
||||||
|
)
|
||||||
|
if append_resp.result != 'OK':
|
||||||
|
logger.warning("update_draft: IMAP APPEND failed for drafts folder %s", folder.imap_name)
|
||||||
|
else:
|
||||||
|
logger.info("update_draft: appended updated draft %s to IMAP Drafts", mail.id)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("update_draft: IMAP sync failed (non-critical): %s", exc)
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
await client.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return mail
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface Mail {
|
|||||||
date: string;
|
date: string;
|
||||||
is_seen: boolean;
|
is_seen: boolean;
|
||||||
is_flagged: boolean;
|
is_flagged: boolean;
|
||||||
|
is_draft: boolean;
|
||||||
is_answered: boolean;
|
is_answered: boolean;
|
||||||
has_attachments: boolean;
|
has_attachments: boolean;
|
||||||
attachments?: MailAttachment[];
|
attachments?: MailAttachment[];
|
||||||
@@ -573,3 +574,31 @@ export function fetchLabels(): Promise<MailLabel[]> {
|
|||||||
export function deleteLabel(labelId: string): Promise<void> {
|
export function deleteLabel(labelId: string): Promise<void> {
|
||||||
return apiDelete<void>(`/mail/labels/${labelId}`);
|
return apiDelete<void>(`/mail/labels/${labelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Delete / Move / Drafts ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function deleteMail(mailId: string): Promise<void> {
|
||||||
|
return apiDelete<void>(`/mail/${mailId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveMail(mailId: string, targetFolderId: string): Promise<Mail> {
|
||||||
|
return apiPost<Mail>(`/mail/${mailId}/move`, { target_folder_id: targetFolderId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailDraftPayload {
|
||||||
|
account_id: string;
|
||||||
|
to: string[];
|
||||||
|
cc: string[];
|
||||||
|
bcc: string[];
|
||||||
|
subject: string;
|
||||||
|
body_text: string;
|
||||||
|
body_html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDraft(payload: MailDraftPayload): Promise<Mail> {
|
||||||
|
return apiPost<Mail>('/mail/drafts', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDraft(mailId: string, payload: MailDraftPayload): Promise<Mail> {
|
||||||
|
return apiPatch<Mail>(`/mail/drafts/${mailId}`, payload);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { Select } from '@/components/ui/Select';
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail';
|
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
|
||||||
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
|
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
|
||||||
|
|
||||||
export type ComposeMode = 'new' | 'reply' | 'forward';
|
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
|
||||||
|
|
||||||
export interface ComposeModalProps {
|
export interface ComposeModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,8 +21,10 @@ export interface ComposeModalProps {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
replyToMail: Mail | null;
|
replyToMail: Mail | null;
|
||||||
forwardMail: Mail | null;
|
forwardMail: Mail | null;
|
||||||
|
draftMail?: Mail | null;
|
||||||
signatures: MailSignature[];
|
signatures: MailSignature[];
|
||||||
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
|
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
|
||||||
|
onSaveDraft?: (payload: MailDraftPayload) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +34,10 @@ export function ComposeModal({
|
|||||||
accountId,
|
accountId,
|
||||||
replyToMail,
|
replyToMail,
|
||||||
forwardMail,
|
forwardMail,
|
||||||
|
draftMail,
|
||||||
signatures,
|
signatures,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSaveDraft,
|
||||||
onClose,
|
onClose,
|
||||||
}: ComposeModalProps) {
|
}: ComposeModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -45,6 +49,7 @@ export function ComposeModal({
|
|||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [showCc, setShowCc] = useState(false);
|
const [showCc, setShowCc] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [savingDraft, setSavingDraft] = useState(false);
|
||||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
||||||
@@ -61,6 +66,13 @@ export function ComposeModal({
|
|||||||
setTo('');
|
setTo('');
|
||||||
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
||||||
setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
|
setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
|
||||||
|
} else if (mode === 'draft' && draftMail) {
|
||||||
|
setTo(draftMail.to_addresses.join(', '));
|
||||||
|
setCc(draftMail.cc_addresses.join(', '));
|
||||||
|
setBcc(draftMail.bcc_addresses.join(', '));
|
||||||
|
setSubject(draftMail.subject);
|
||||||
|
setBody(draftMail.body_html || draftMail.body_text || '');
|
||||||
|
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
|
||||||
} else {
|
} else {
|
||||||
setTo('');
|
setTo('');
|
||||||
setCc('');
|
setCc('');
|
||||||
@@ -69,7 +81,7 @@ export function ComposeModal({
|
|||||||
setBody('');
|
setBody('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
}
|
}
|
||||||
}, [open, mode, replyToMail, forwardMail, t]);
|
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
|
||||||
|
|
||||||
const execCommand = useCallback((command: string, value?: string) => {
|
const execCommand = useCallback((command: string, value?: string) => {
|
||||||
document.execCommand(command, false, value);
|
document.execCommand(command, false, value);
|
||||||
@@ -142,6 +154,28 @@ export function ComposeModal({
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = useCallback(async () => {
|
||||||
|
if (!onSaveDraft) return;
|
||||||
|
setSavingDraft(true);
|
||||||
|
try {
|
||||||
|
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||||
|
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||||
|
const payload: MailDraftPayload = {
|
||||||
|
account_id: accountId,
|
||||||
|
to: toList,
|
||||||
|
cc: ccList,
|
||||||
|
bcc: bccList,
|
||||||
|
subject,
|
||||||
|
body_text: body.replace(/<[^>]*>/g, ''),
|
||||||
|
body_html: body,
|
||||||
|
};
|
||||||
|
await onSaveDraft(payload);
|
||||||
|
} finally {
|
||||||
|
setSavingDraft(false);
|
||||||
|
}
|
||||||
|
}, [to, cc, bcc, subject, body, accountId, onSaveDraft]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!to.trim()) return;
|
if (!to.trim()) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
@@ -190,7 +224,7 @@ export function ComposeModal({
|
|||||||
}
|
}
|
||||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
||||||
|
|
||||||
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
|
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
|
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
|
||||||
@@ -379,6 +413,17 @@ export function ComposeModal({
|
|||||||
<Button variant="secondary" onClick={onClose} type="button">
|
<Button variant="secondary" onClick={onClose} type="button">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{onSaveDraft && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
isLoading={savingDraft}
|
||||||
|
type="button"
|
||||||
|
data-testid="compose-save-draft"
|
||||||
|
>
|
||||||
|
{t('mail.saveDraft')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
||||||
{t('mail.send')}
|
{t('mail.send')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface MailDetailProps {
|
|||||||
onToggleFlag: (mail: Mail) => void;
|
onToggleFlag: (mail: Mail) => void;
|
||||||
onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void;
|
onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void;
|
||||||
downloadingAttachmentId: string | null;
|
downloadingAttachmentId: string | null;
|
||||||
|
onDelete?: (mail: Mail) => void;
|
||||||
|
onMove?: (mail: Mail) => void;
|
||||||
|
onEditDraft?: (mail: Mail) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFullDate(dateStr: string): string {
|
function formatFullDate(dateStr: string): string {
|
||||||
@@ -47,6 +50,9 @@ export function MailDetail({
|
|||||||
onToggleFlag,
|
onToggleFlag,
|
||||||
onDownloadAttachment,
|
onDownloadAttachment,
|
||||||
downloadingAttachmentId,
|
downloadingAttachmentId,
|
||||||
|
onDelete,
|
||||||
|
onMove,
|
||||||
|
onEditDraft,
|
||||||
}: MailDetailProps) {
|
}: MailDetailProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -98,6 +104,16 @@ export function MailDetail({
|
|||||||
<div className="flex flex-col h-full" data-testid="mail-detail">
|
<div className="flex flex-col h-full" data-testid="mail-detail">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-1 sm:gap-2 px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200 overflow-x-auto" data-testid="mail-detail-toolbar">
|
<div className="flex items-center gap-1 sm:gap-2 px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200 overflow-x-auto" data-testid="mail-detail-toolbar">
|
||||||
|
{mail.is_draft && onEditDraft ? (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEditDraft(mail)} icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
}>
|
||||||
|
{t('mail.editDraft')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button variant="secondary" size="sm" onClick={() => onReply(mail)} icon={
|
<Button variant="secondary" size="sm" onClick={() => onReply(mail)} icon={
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
@@ -107,11 +123,13 @@ export function MailDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => onForward(mail)} icon={
|
<Button variant="secondary" size="sm" onClick={() => onForward(mail)} icon={
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6-6m6 6l-6 6" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
}>
|
}>
|
||||||
{t('mail.forward')}
|
{t('mail.forward')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -125,6 +143,20 @@ export function MailDetail({
|
|||||||
>
|
>
|
||||||
{mail.is_flagged ? t('mail.unflag') : t('mail.flag')}
|
{mail.is_flagged ? t('mail.unflag') : t('mail.flag')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{onMove && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onMove(mail)}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('mail.move')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -138,6 +170,20 @@ export function MailDetail({
|
|||||||
>
|
>
|
||||||
{t('mail.createEvent')}
|
{t('mail.createEvent')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(mail)}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('mail.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Headers */}
|
{/* Headers */}
|
||||||
|
|||||||
@@ -540,6 +540,19 @@
|
|||||||
"markUnread": "Als ungelesen markieren",
|
"markUnread": "Als ungelesen markieren",
|
||||||
"selectAll": "Alle auswählen",
|
"selectAll": "Alle auswählen",
|
||||||
"bulkActions": "Sammelaktionen",
|
"bulkActions": "Sammelaktionen",
|
||||||
"selectedCount": "{{count}} ausgewählt"
|
"selectedCount": "{{count}} ausgewählt",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"deleteConfirm": "Möchten Sie diese E-Mail wirklich löschen?",
|
||||||
|
"deleteMail": "E-Mail löschen",
|
||||||
|
"bulkDelete": "Alle löschen",
|
||||||
|
"bulkDeleteConfirm": "Möchten Sie die ausgewählten E-Mails wirklich löschen?",
|
||||||
|
"move": "Verschieben",
|
||||||
|
"moveTo": "Verschieben nach",
|
||||||
|
"moveMail": "E-Mail verschieben",
|
||||||
|
"bulkMove": "Alle verschieben",
|
||||||
|
"saveDraft": "Entwurf speichern",
|
||||||
|
"editDraft": "Entwurf bearbeiten",
|
||||||
|
"draftSaved": "Entwurf gespeichert.",
|
||||||
|
"draft": "Entwurf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -540,6 +540,19 @@
|
|||||||
"markUnread": "Mark as Unread",
|
"markUnread": "Mark as Unread",
|
||||||
"selectAll": "Select All",
|
"selectAll": "Select All",
|
||||||
"bulkActions": "Bulk Actions",
|
"bulkActions": "Bulk Actions",
|
||||||
"selectedCount": "{{count}} selected"
|
"selectedCount": "{{count}} selected",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this email?",
|
||||||
|
"deleteMail": "Delete Email",
|
||||||
|
"bulkDelete": "Delete All",
|
||||||
|
"bulkDeleteConfirm": "Are you sure you want to delete the selected emails?",
|
||||||
|
"move": "Move",
|
||||||
|
"moveTo": "Move to",
|
||||||
|
"moveMail": "Move Email",
|
||||||
|
"bulkMove": "Move All",
|
||||||
|
"saveDraft": "Save Draft",
|
||||||
|
"editDraft": "Edit Draft",
|
||||||
|
"draftSaved": "Draft saved.",
|
||||||
|
"draft": "Draft"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-1
@@ -32,6 +32,10 @@ import {
|
|||||||
downloadAttachment,
|
downloadAttachment,
|
||||||
fetchSignatures,
|
fetchSignatures,
|
||||||
searchMails,
|
searchMails,
|
||||||
|
deleteMail,
|
||||||
|
moveMail,
|
||||||
|
saveDraft,
|
||||||
|
updateDraft,
|
||||||
type MailAccount,
|
type MailAccount,
|
||||||
type MailFolder,
|
type MailFolder,
|
||||||
type Mail,
|
type Mail,
|
||||||
@@ -41,6 +45,7 @@ import {
|
|||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
type ForwardPayload,
|
type ForwardPayload,
|
||||||
type CreateEventFromMailPayload,
|
type CreateEventFromMailPayload,
|
||||||
|
type MailDraftPayload,
|
||||||
} from '@/api/mail';
|
} from '@/api/mail';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
@@ -67,10 +72,12 @@ export function MailPage() {
|
|||||||
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
|
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
|
||||||
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
|
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
|
||||||
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
||||||
|
const [draftMailState, setDraftMailState] = useState<Mail | null>(null);
|
||||||
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
||||||
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
|
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showMoveDropdown, setShowMoveDropdown] = useState(false);
|
||||||
|
|
||||||
// Load accounts
|
// Load accounts
|
||||||
const loadAccounts = useCallback(async () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
@@ -204,6 +211,7 @@ export function MailPage() {
|
|||||||
setComposeMode('new');
|
setComposeMode('new');
|
||||||
setReplyToMail(null);
|
setReplyToMail(null);
|
||||||
setForwardMailState(null);
|
setForwardMailState(null);
|
||||||
|
setDraftMailState(null);
|
||||||
setComposeOpen(true);
|
setComposeOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -212,6 +220,7 @@ export function MailPage() {
|
|||||||
setComposeMode('reply');
|
setComposeMode('reply');
|
||||||
setReplyToMail(mail);
|
setReplyToMail(mail);
|
||||||
setForwardMailState(null);
|
setForwardMailState(null);
|
||||||
|
setDraftMailState(null);
|
||||||
setComposeOpen(true);
|
setComposeOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -220,6 +229,98 @@ export function MailPage() {
|
|||||||
setComposeMode('forward');
|
setComposeMode('forward');
|
||||||
setForwardMailState(mail);
|
setForwardMailState(mail);
|
||||||
setReplyToMail(null);
|
setReplyToMail(null);
|
||||||
|
setDraftMailState(null);
|
||||||
|
setComposeOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle delete single mail
|
||||||
|
const handleDeleteMail = useCallback(
|
||||||
|
async (mail: Mail) => {
|
||||||
|
if (!window.confirm(t('mail.deleteConfirm'))) return;
|
||||||
|
try {
|
||||||
|
await deleteMail(mail.id);
|
||||||
|
setMails((prev) => prev.filter((m) => m.id !== mail.id));
|
||||||
|
setSelectedMail(null);
|
||||||
|
toast.success(t('mail.delete'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle bulk delete
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
if (!window.confirm(t('mail.bulkDeleteConfirm'))) return;
|
||||||
|
const ids = Array.from(selectedMailIds);
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map((id) => deleteMail(id)));
|
||||||
|
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
|
||||||
|
setSelectedMailIds(new Set());
|
||||||
|
setSelectedMail(null);
|
||||||
|
toast.success(t('mail.delete'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}, [selectedMailIds, toast, t]);
|
||||||
|
|
||||||
|
// Handle move single mail
|
||||||
|
const handleMoveMail = useCallback(
|
||||||
|
async (mail: Mail, targetFolderId: string) => {
|
||||||
|
try {
|
||||||
|
await moveMail(mail.id, targetFolderId);
|
||||||
|
setMails((prev) => prev.filter((m) => m.id !== mail.id));
|
||||||
|
setSelectedMail(null);
|
||||||
|
setShowMoveDropdown(false);
|
||||||
|
toast.success(t('mail.move'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle bulk move
|
||||||
|
const handleBulkMove = useCallback(
|
||||||
|
async (targetFolderId: string) => {
|
||||||
|
const ids = Array.from(selectedMailIds);
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map((id) => moveMail(id, targetFolderId)));
|
||||||
|
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
|
||||||
|
setSelectedMailIds(new Set());
|
||||||
|
setShowMoveDropdown(false);
|
||||||
|
toast.success(t('mail.move'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedMailIds, toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle save draft (new or existing)
|
||||||
|
const handleSaveDraft = useCallback(
|
||||||
|
async (payload: MailDraftPayload) => {
|
||||||
|
try {
|
||||||
|
if (composeMode === 'draft' && draftMailState) {
|
||||||
|
await updateDraft(draftMailState.id, payload);
|
||||||
|
} else {
|
||||||
|
await saveDraft(payload);
|
||||||
|
}
|
||||||
|
toast.success(t('mail.draftSaved'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[composeMode, draftMailState, toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle edit draft
|
||||||
|
const handleEditDraft = useCallback((mail: Mail) => {
|
||||||
|
setComposeMode('draft');
|
||||||
|
setDraftMailState(mail);
|
||||||
|
setReplyToMail(null);
|
||||||
|
setForwardMailState(null);
|
||||||
setComposeOpen(true);
|
setComposeOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -453,6 +554,32 @@ export function MailPage() {
|
|||||||
),
|
),
|
||||||
onClick: () => selectedMail && handleCreateEvent(selectedMail),
|
onClick: () => selectedMail && handleCreateEvent(selectedMail),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
plugin: 'mail',
|
||||||
|
label: t('mail.delete'),
|
||||||
|
group: 'mail-actions',
|
||||||
|
disabled: !hasMail,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: () => selectedMail && handleDeleteMail(selectedMail),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'move',
|
||||||
|
plugin: 'mail',
|
||||||
|
label: t('mail.move'),
|
||||||
|
group: 'mail-actions',
|
||||||
|
disabled: !hasMail,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: () => setShowMoveDropdown((v) => !v),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sync',
|
id: 'sync',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
@@ -492,11 +619,35 @@ export function MailPage() {
|
|||||||
),
|
),
|
||||||
onClick: handleBulkMarkUnread,
|
onClick: handleBulkMarkUnread,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'bulk-delete',
|
||||||
|
plugin: 'mail',
|
||||||
|
label: t('mail.bulkDelete'),
|
||||||
|
group: 'bulk-actions',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: handleBulkDelete,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bulk-move',
|
||||||
|
plugin: 'mail',
|
||||||
|
label: t('mail.bulkMove'),
|
||||||
|
group: 'bulk-actions',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: () => setShowMoveDropdown((v) => !v),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
registerItems('mail', items);
|
registerItems('mail', items);
|
||||||
return () => unregisterPlugin('mail');
|
return () => unregisterPlugin('mail');
|
||||||
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleBulkMarkRead, handleBulkMarkUnread, t]);
|
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleDeleteMail, handleBulkDelete, handleBulkMarkRead, handleBulkMarkUnread, t]);
|
||||||
|
|
||||||
if (loadingAccounts) {
|
if (loadingAccounts) {
|
||||||
return (
|
return (
|
||||||
@@ -589,6 +740,9 @@ export function MailPage() {
|
|||||||
onToggleFlag={handleToggleFlag}
|
onToggleFlag={handleToggleFlag}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
downloadingAttachmentId={downloadingAttachmentId}
|
downloadingAttachmentId={downloadingAttachmentId}
|
||||||
|
onDelete={handleDeleteMail}
|
||||||
|
onMove={(mail) => setShowMoveDropdown(true)}
|
||||||
|
onEditDraft={handleEditDraft}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</div>
|
</div>
|
||||||
@@ -667,6 +821,9 @@ export function MailPage() {
|
|||||||
onToggleFlag={handleToggleFlag}
|
onToggleFlag={handleToggleFlag}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
downloadingAttachmentId={downloadingAttachmentId}
|
downloadingAttachmentId={downloadingAttachmentId}
|
||||||
|
onDelete={handleDeleteMail}
|
||||||
|
onMove={(mail) => setShowMoveDropdown(true)}
|
||||||
|
onEditDraft={handleEditDraft}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -688,20 +845,66 @@ export function MailPage() {
|
|||||||
<Button variant="secondary" size="sm" onClick={handleBulkMarkUnread}>
|
<Button variant="secondary" size="sm" onClick={handleBulkMarkUnread}>
|
||||||
{t('mail.markUnread')}
|
{t('mail.markUnread')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={handleBulkDelete}>
|
||||||
|
{t('mail.bulkDelete')}
|
||||||
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowMoveDropdown((v) => !v)}>
|
||||||
|
{t('mail.bulkMove')}
|
||||||
|
</Button>
|
||||||
|
{showMoveDropdown && (
|
||||||
|
<div className="absolute bottom-full mb-2 left-0 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48">
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() => handleBulkMove(folder.id)}
|
||||||
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
|
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
|
||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Move dropdown for single mail */}
|
||||||
|
{showMoveDropdown && selectedMail && selectedMailIds.size === 0 && (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 z-50 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48"
|
||||||
|
data-testid="mail-move-dropdown"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2 text-xs font-medium text-secondary-500 border-b border-secondary-100">
|
||||||
|
{t('mail.moveTo')}
|
||||||
|
</div>
|
||||||
|
{folders
|
||||||
|
.filter((f) => f.account_id === selectedMail.account_id)
|
||||||
|
.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() => handleMoveMail(selectedMail, folder.id)}
|
||||||
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ComposeModal
|
<ComposeModal
|
||||||
open={composeOpen}
|
open={composeOpen}
|
||||||
mode={composeMode}
|
mode={composeMode}
|
||||||
accountId={selectedAccountId}
|
accountId={selectedAccountId}
|
||||||
replyToMail={replyToMail}
|
replyToMail={replyToMail}
|
||||||
forwardMail={forwardMailState}
|
forwardMail={forwardMailState}
|
||||||
|
draftMail={draftMailState}
|
||||||
signatures={signatures}
|
signatures={signatures}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
onClose={() => setComposeOpen(false)}
|
onClose={() => setComposeOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user