Email client fix

This commit is contained in:
2026-09-11 14:23:07 +03:00
parent ea96c7a2ab
commit 166a0bea71
7 changed files with 141 additions and 7 deletions
+46
View File
@@ -87,6 +87,33 @@ def _message_body(msg: email.message.Message) -> str:
return payload.decode(charset, errors='replace')
def _iter_file_parts(msg: email.message.Message):
"""Вложения: disposition=attachment или файл с именем не text/*."""
index = 0
for part in msg.walk():
if part.is_multipart():
continue
filename = part.get_filename()
disp = (part.get_content_disposition() or '').lower()
ctype = (part.get_content_type() or '').lower()
is_file = disp == 'attachment' or (
bool(filename) and ctype not in ('text/plain', 'text/html')
)
if not is_file:
continue
name = _decode_mime(filename) or f'attachment-{index + 1}'
payload = part.get_payload(decode=True) or b''
yield index, name, payload, ctype or 'application/octet-stream'
index += 1
def _attachment_summaries(msg: email.message.Message) -> list[dict]:
items = []
for index, name, payload, _ctype in _iter_file_parts(msg):
items.append({'index': index, 'name': name, 'size': len(payload)})
return items
class ImapMailClient:
def __init__(self, settings: MailSettings, username: str, password: str):
self.settings = settings
@@ -240,8 +267,27 @@ class ImapMailClient:
p.get_content_type() == 'text/html' for p in msg.walk()
)
),
'attachments': _attachment_summaries(msg),
}
def fetch_attachment(self, uid: str, folder_key: str, index: int) -> tuple[str, bytes, str]:
assert self._conn
uid = (uid or '').strip()
if not uid.isdigit():
raise MailConnectionError('Лист не знайдено')
imap_folder = PORTAL_FOLDERS.get(folder_key, 'INBOX')
self._select_folder(imap_folder)
typ, data = self._conn.uid('fetch', uid, '(RFC822)')
if typ != 'OK' or not data or not data[0]:
raise MailConnectionError('Лист не знайдено')
raw = data[0][1]
msg = email.message_from_bytes(raw)
parts = list(_iter_file_parts(msg))
if index < 0 or index >= len(parts):
raise MailConnectionError('Файл не знайдено')
_i, name, payload, ctype = parts[index]
return name, payload, ctype
def unread_count(self, folder_key: str = 'inbox') -> int:
assert self._conn
imap_folder = PORTAL_FOLDERS.get(folder_key, 'INBOX')
+40
View File
@@ -232,3 +232,43 @@ class MailViewTests(TestCase):
client_cls.return_value.__enter__.side_effect = MailConnectionError('AUTH failed')
response = self.client.post('/email/connect/', {'password': 'wrong'}, HTTP_HX_REQUEST='true')
self.assertContains(response, 'AUTH failed')
class MailAttachmentParseTests(TestCase):
def test_lists_named_attachment(self):
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from apps.mail_connector.imap_client import _attachment_summaries
msg = MIMEMultipart()
msg.attach(MIMEText('hello', 'plain'))
att = MIMEApplication(b'%PDF-1.4 mock', _subtype='pdf')
att.add_header('Content-Disposition', 'attachment', filename='act.pdf')
msg.attach(att)
items = _attachment_summaries(msg)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]['name'], 'act.pdf')
self.assertEqual(items[0]['index'], 0)
@mock.patch('apps.mail_connector.views.ImapMailClient')
def test_download_attachment(self, client_cls):
ConnectorConfig.objects.create(
connector_type='mail',
is_enabled=True,
host='mail.test.local',
username='admin',
password='secret',
)
mock_client = mock.Mock()
client_cls.return_value.__enter__.return_value = mock_client
client_cls.return_value.__exit__.return_value = False
mock_client.fetch_attachment.return_value = ('act.pdf', b'%PDF', 'application/pdf')
user = make_employee(email='att@aks-apteka.com.ua')
grant_email_access(user)
login_2fa(self.client, user)
set_client_mail_password(self.client, 'imap-secret')
r = self.client.get('/email/attachment/?folder=inbox&uid=12&i=0')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.content, b'%PDF')
mock_client.fetch_attachment.assert_called_once()
+1
View File
@@ -7,4 +7,5 @@ urlpatterns = [
path('email/connect/', views.email_connect, name='email_connect'),
path('email/disconnect/', views.email_disconnect, name='email_disconnect'),
path('email/send/', views.email_send, name='email_send'),
path('email/attachment/', views.email_attachment, name='email_attachment'),
]
+26 -1
View File
@@ -4,7 +4,7 @@ import logging
import re
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseForbidden
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import render
from django.views.decorators.http import require_POST
@@ -271,3 +271,28 @@ def email_send(request):
if result:
ctx['messages'], ctx['unread_count'] = result
return render_page(request, 'email.html', ctx)
@_email_guard
def email_attachment(request):
from urllib.parse import quote
uid = (request.GET.get('uid') or '').strip()
folder = request.GET.get('folder', 'inbox')
if folder == 'starred':
folder = 'inbox'
try:
index = int(request.GET.get('i', ''))
except (TypeError, ValueError):
raise Http404('Файл не знайдено')
def load(client):
return client.fetch_attachment(uid, folder, index)
result, _err_key, _err_msg = _with_imap(request, load)
if not result:
raise Http404('Файл не знайдено')
name, payload, ctype = result
resp = HttpResponse(payload, content_type=ctype or 'application/octet-stream')
resp['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(name)}"
return resp
+12 -4
View File
@@ -84,7 +84,7 @@ class NextcloudWebDAVService:
if resp.status_code != 207:
self._raise(resp, relpath)
self_href = unquote(url.split('/remote.php')[1]).rstrip('/')
self_path = unquote(url.split('/remote.php', 1)[-1]).rstrip('/')
entries: list[dict] = []
root = ET.fromstring(resp.content)
for r in root.findall(f'{DAV_NS}response'):
@@ -92,12 +92,20 @@ class NextcloudWebDAVService:
if href_el is None or not href_el.text:
continue
href = unquote(href_el.text)
if href.rstrip('/').endswith(self_href):
continue # это сама папка, не её содержимое
if '/remote.php' in href:
path = href.split('/remote.php', 1)[-1].rstrip('/')
else:
path = href.rstrip('/')
if path == self_path:
continue
prop = r.find(f'{DAV_NS}propstat/{DAV_NS}prop')
is_dir = prop is not None and prop.find(
f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None
name = unquote(href.rstrip('/')).rsplit('/', 1)[-1]
name_el = prop.find(f'{DAV_NS}displayname') if prop is not None else None
name = (name_el.text if name_el is not None and name_el.text else '') or path.rsplit('/', 1)[-1]
name = unquote(name).strip()
if not name or name in ('.', '..'):
continue
size_el = prop.find(f'{DAV_NS}getcontentlength') if prop is not None else None
mod_el = prop.find(f'{DAV_NS}getlastmodified') if prop is not None else None
entries.append({