fix: mail sync duplicates, sent folder, delimiter detection, folder move tracking
- Add imap_uid column + unique index for proper deduplication - Use deterministic message_id for emails without Message-ID header - Dedup by (account_id, folder_id, imap_uid) first, message_id fallback - Track folder moves: update folder_id when email appears in different folder - Fix Sent folder lookup: check Sent/INBOX.Sent/Sent Items/Sent Mail + ILIKE - Detect IMAP delimiter from LIST response instead of hardcoding dot - Use detected delimiter in stale folder name migration - Migration 0004_imap_uid.sql with imap_uid column and unique index
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add imap_uid column to mails table for proper deduplication
|
||||||
|
ALTER TABLE mails ADD COLUMN IF NOT EXISTS imap_uid VARCHAR(20);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_mails_imap_uid ON mails(account_id, imap_uid);
|
||||||
|
-- Unique constraint to prevent duplicates per folder
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_mails_account_folder_uid ON mails(account_id, folder_id, imap_uid) WHERE imap_uid IS NOT NULL;
|
||||||
@@ -111,6 +111,7 @@ class Mail(Base, TenantMixin):
|
|||||||
ForeignKey("mail_folders.id", ondelete="CASCADE"),
|
ForeignKey("mail_folders.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
imap_uid: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
message_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
message_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
thread_id: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
thread_id: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||||
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
|||||||
@@ -411,14 +411,18 @@ def _get_german_folder_name(imap_name: str) -> str:
|
|||||||
return imap_name
|
return imap_name
|
||||||
|
|
||||||
|
|
||||||
def _parse_imap_list_response(response) -> list[tuple[str, str]]:
|
def _parse_imap_list_response(response) -> tuple[list[tuple[str, str]], str]:
|
||||||
"""Parse IMAP LIST response into list of (flags, folder_name) tuples.
|
"""Parse IMAP LIST response into list of (flags, folder_name) tuples.
|
||||||
|
|
||||||
Handles both "/" and "." delimiters. The IMAP LIST response format is:
|
Handles both "/" and "." delimiters. The IMAP LIST response format is:
|
||||||
* LIST (\\HasChildren) "." "INBOX"
|
* LIST (\\HasChildren) "." "INBOX"
|
||||||
* LIST (\\HasNoChildren) "." "INBOX.Sent"
|
* LIST (\\HasNoChildren) "." "INBOX.Sent"
|
||||||
|
|
||||||
|
Returns (folders, delimiter) where delimiter is the hierarchy separator
|
||||||
|
detected from the LIST response (defaults to '.' if not found).
|
||||||
"""
|
"""
|
||||||
folders: list[tuple[str, str]] = []
|
folders: list[tuple[str, str]] = []
|
||||||
|
delimiter = '.'
|
||||||
lines = response.lines if hasattr(response, 'lines') else response
|
lines = response.lines if hasattr(response, 'lines') else response
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if isinstance(line, (bytes, bytearray)):
|
if isinstance(line, (bytes, bytearray)):
|
||||||
@@ -441,7 +445,7 @@ def _parse_imap_list_response(response) -> list[tuple[str, str]]:
|
|||||||
folder_name = parts[-2] if len(parts) >= 2 else ''
|
folder_name = parts[-2] if len(parts) >= 2 else ''
|
||||||
if folder_name:
|
if folder_name:
|
||||||
folders.append(('', folder_name))
|
folders.append(('', folder_name))
|
||||||
return folders
|
return folders, delimiter
|
||||||
|
|
||||||
|
|
||||||
def _build_folder_hierarchy(
|
def _build_folder_hierarchy(
|
||||||
@@ -449,15 +453,15 @@ def _build_folder_hierarchy(
|
|||||||
account_id: uuid.UUID,
|
account_id: uuid.UUID,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
existing_folders: dict[str, MailFolder],
|
existing_folders: dict[str, MailFolder],
|
||||||
|
delimiter: str = '.',
|
||||||
) -> list[MailFolder]:
|
) -> list[MailFolder]:
|
||||||
"""Create or update MailFolder records from IMAP LIST response.
|
"""Create or update MailFolder records from IMAP LIST response.
|
||||||
|
|
||||||
Handles dot-delimited hierarchies (e.g. INBOX.Sent → parent=INBOX).
|
Handles delimiter-separated hierarchies (e.g. INBOX.Sent → parent=INBOX).
|
||||||
Updates existing folders in-place (name, is_standard, parent_id) so
|
Updates existing folders in-place (name, is_standard, parent_id) so
|
||||||
that stale DB records with wrong imap_name values get corrected.
|
that stale DB records with wrong imap_name values get corrected.
|
||||||
"""
|
"""
|
||||||
result: list[MailFolder] = []
|
result: list[MailFolder] = []
|
||||||
delimiter = '.'
|
|
||||||
|
|
||||||
# First pass: create or update folder records
|
# First pass: create or update folder records
|
||||||
for flags, imap_name in imap_folders:
|
for flags, imap_name in imap_folders:
|
||||||
@@ -598,7 +602,7 @@ async def imap_sync_account(
|
|||||||
try:
|
try:
|
||||||
# 1) LIST all folders from IMAP server
|
# 1) LIST all folders from IMAP server
|
||||||
list_response = await client.list('""', '"*"')
|
list_response = await client.list('""', '"*"')
|
||||||
imap_folders = _parse_imap_list_response(list_response)
|
imap_folders, imap_delimiter = _parse_imap_list_response(list_response)
|
||||||
|
|
||||||
# If LIST returned nothing, fall back to standard folders (dot-delimited)
|
# If LIST returned nothing, fall back to standard folders (dot-delimited)
|
||||||
if not imap_folders:
|
if not imap_folders:
|
||||||
@@ -627,10 +631,10 @@ async def imap_sync_account(
|
|||||||
imap_names_from_server = {name for _, name in imap_folders if name}
|
imap_names_from_server = {name for _, name in imap_folders if name}
|
||||||
for db_folder in existing_db_folders:
|
for db_folder in existing_db_folders:
|
||||||
if db_folder.imap_name not in imap_names_from_server:
|
if db_folder.imap_name not in imap_names_from_server:
|
||||||
# Try matching by leaf component
|
# Try matching by leaf component using detected delimiter
|
||||||
leaf = db_folder.imap_name.rsplit(".", 1)[-1]
|
leaf = db_folder.imap_name.rsplit(imap_delimiter, 1)[-1]
|
||||||
for srv_name in imap_names_from_server:
|
for srv_name in imap_names_from_server:
|
||||||
srv_leaf = srv_name.rsplit(".", 1)[-1]
|
srv_leaf = srv_name.rsplit(imap_delimiter, 1)[-1]
|
||||||
if srv_leaf.lower() == leaf.lower():
|
if srv_leaf.lower() == leaf.lower():
|
||||||
db_folder.imap_name = srv_name
|
db_folder.imap_name = srv_name
|
||||||
existing_by_imap[srv_name] = db_folder
|
existing_by_imap[srv_name] = db_folder
|
||||||
@@ -638,7 +642,7 @@ async def imap_sync_account(
|
|||||||
|
|
||||||
# 3) Create/update folders in DB
|
# 3) Create/update folders in DB
|
||||||
db_folders = _build_folder_hierarchy(
|
db_folders = _build_folder_hierarchy(
|
||||||
imap_folders, account.id, tenant_id, existing_by_imap
|
imap_folders, account.id, tenant_id, existing_by_imap, imap_delimiter
|
||||||
)
|
)
|
||||||
for folder in db_folders:
|
for folder in db_folders:
|
||||||
if folder.id is None:
|
if folder.id is None:
|
||||||
@@ -646,12 +650,11 @@ async def imap_sync_account(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 3a) Re-set parent_id now that new folders have IDs after flush
|
# 3a) Re-set parent_id now that new folders have IDs after flush
|
||||||
delimiter = '.'
|
|
||||||
folder_by_imap_post_flush = {f.imap_name: f for f in db_folders}
|
folder_by_imap_post_flush = {f.imap_name: f for f in db_folders}
|
||||||
for folder in db_folders:
|
for folder in db_folders:
|
||||||
if delimiter in folder.imap_name:
|
if imap_delimiter in folder.imap_name:
|
||||||
parts = folder.imap_name.split(delimiter)
|
parts = folder.imap_name.split(imap_delimiter)
|
||||||
parent_imap = delimiter.join(parts[:-1])
|
parent_imap = imap_delimiter.join(parts[:-1])
|
||||||
if parent_imap in folder_by_imap_post_flush:
|
if parent_imap in folder_by_imap_post_flush:
|
||||||
parent = folder_by_imap_post_flush[parent_imap]
|
parent = folder_by_imap_post_flush[parent_imap]
|
||||||
if parent.id:
|
if parent.id:
|
||||||
@@ -751,7 +754,12 @@ async def imap_sync_account(
|
|||||||
except Exception:
|
except Exception:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
message_id = msg.get("Message-ID", make_msgid())
|
raw_msg_id = msg.get("Message-ID")
|
||||||
|
if raw_msg_id:
|
||||||
|
message_id = raw_msg_id
|
||||||
|
else:
|
||||||
|
# Deterministic ID so re-syncs don't create duplicates
|
||||||
|
message_id = f"generated-{account.id}-{folder.id}-{uid_str}"
|
||||||
subject = _decode_mime_header(msg.get("Subject", ""))
|
subject = _decode_mime_header(msg.get("Subject", ""))
|
||||||
from_addr = _decode_mime_header(msg.get("From", ""))
|
from_addr = _decode_mime_header(msg.get("From", ""))
|
||||||
to_addrs = _decode_mime_header(msg.get("To", ""))
|
to_addrs = _decode_mime_header(msg.get("To", ""))
|
||||||
@@ -774,7 +782,23 @@ async def imap_sync_account(
|
|||||||
# Compute thread_id from References/In-Reply-To
|
# Compute thread_id from References/In-Reply-To
|
||||||
thread_id = _compute_thread_id(message_id, refs, in_reply_to)
|
thread_id = _compute_thread_id(message_id, refs, in_reply_to)
|
||||||
|
|
||||||
# Check if email already exists (by message_id + folder)
|
# Dedup: first check by (account_id, folder_id, imap_uid),
|
||||||
|
# then fall back to (account_id, message_id) for mails synced
|
||||||
|
# before the imap_uid column existed.
|
||||||
|
existing_mail = (
|
||||||
|
await db.execute(
|
||||||
|
select(Mail).where(
|
||||||
|
and_(
|
||||||
|
Mail.account_id == account.id,
|
||||||
|
Mail.folder_id == folder.id,
|
||||||
|
Mail.imap_uid == uid_str,
|
||||||
|
Mail.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not existing_mail:
|
||||||
existing_mail = (
|
existing_mail = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Mail).where(
|
select(Mail).where(
|
||||||
@@ -786,7 +810,16 @@ async def imap_sync_account(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if existing_mail:
|
if existing_mail:
|
||||||
|
# Track folder moves: update folder_id and imap_uid if the
|
||||||
|
# mail now appears in a different IMAP folder.
|
||||||
|
if existing_mail.folder_id != folder.id:
|
||||||
|
existing_mail.folder_id = folder.id
|
||||||
|
existing_mail.imap_uid = uid_str
|
||||||
|
elif not existing_mail.imap_uid:
|
||||||
|
existing_mail.imap_uid = uid_str
|
||||||
|
|
||||||
# Save attachments for existing emails that have has_attachments but no records
|
# Save attachments for existing emails that have has_attachments but no records
|
||||||
if attachments and existing_mail.has_attachments:
|
if attachments and existing_mail.has_attachments:
|
||||||
existing_att_count = (
|
existing_att_count = (
|
||||||
@@ -827,6 +860,7 @@ async def imap_sync_account(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
folder_id=folder.id,
|
folder_id=folder.id,
|
||||||
|
imap_uid=uid_str,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
in_reply_to=in_reply_to,
|
in_reply_to=in_reply_to,
|
||||||
@@ -1045,13 +1079,29 @@ async def send_mail_via_smtp(
|
|||||||
await smtp.send_message(msg, recipients=recipients)
|
await smtp.send_message(msg, recipients=recipients)
|
||||||
await smtp.quit()
|
await smtp.quit()
|
||||||
|
|
||||||
# Store sent mail in Sent folder
|
# Store sent mail in Sent folder — flexible lookup to handle
|
||||||
|
# different IMAP naming conventions (Sent, INBOX.Sent, Sent Items, etc.)
|
||||||
sent_folder = (
|
sent_folder = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(MailFolder).where(
|
select(MailFolder).where(
|
||||||
and_(
|
and_(
|
||||||
MailFolder.account_id == account.id,
|
MailFolder.account_id == account.id,
|
||||||
MailFolder.imap_name == "Sent",
|
MailFolder.imap_name.in_(
|
||||||
|
["Sent", "INBOX.Sent", "Sent Items", "Sent Mail"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not sent_folder:
|
||||||
|
sent_folder = (
|
||||||
|
await db.execute(
|
||||||
|
select(MailFolder).where(
|
||||||
|
and_(
|
||||||
|
MailFolder.account_id == account.id,
|
||||||
|
MailFolder.is_standard == True,
|
||||||
|
MailFolder.imap_name.ilike("%sent%"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user