diff --git a/apps/mail_connector/imap_client.py b/apps/mail_connector/imap_client.py index 65a8226..d6a4c19 100644 --- a/apps/mail_connector/imap_client.py +++ b/apps/mail_connector/imap_client.py @@ -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') diff --git a/apps/mail_connector/tests.py b/apps/mail_connector/tests.py index e5e9479..88c8321 100644 --- a/apps/mail_connector/tests.py +++ b/apps/mail_connector/tests.py @@ -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() diff --git a/apps/mail_connector/urls.py b/apps/mail_connector/urls.py index 9ae66f8..39d0624 100644 --- a/apps/mail_connector/urls.py +++ b/apps/mail_connector/urls.py @@ -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'), ] diff --git a/apps/mail_connector/views.py b/apps/mail_connector/views.py index 856b2be..b6fa2e9 100644 --- a/apps/mail_connector/views.py +++ b/apps/mail_connector/views.py @@ -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 diff --git a/apps/nextcloud_connector/webdav_service.py b/apps/nextcloud_connector/webdav_service.py index 4e8b31c..cbba98c 100644 --- a/apps/nextcloud_connector/webdav_service.py +++ b/apps/nextcloud_connector/webdav_service.py @@ -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({ diff --git a/templates/pages/email.html b/templates/pages/email.html index a338f33..364ac20 100644 --- a/templates/pages/email.html +++ b/templates/pages/email.html @@ -125,6 +125,20 @@
{% if message.is_html %}{{ message.body|striptags|linebreaksbr }}{% else %}{{ message.body|linebreaksbr }}{% endif %}
+ {% if message.attachments %} +
+
Вкладення
+
+ {% for att in message.attachments %} + + 📎 {{ att.name }} + {% if att.size %}{{ att.size|filesizeformat }}{% endif %} + + {% endfor %} +
+
+ {% endif %} {% elif view == 'read' and read_error %} diff --git a/templates/partials/documents-browser.html b/templates/partials/documents-browser.html index 020c197..0182404 100644 --- a/templates/partials/documents-browser.html +++ b/templates/partials/documents-browser.html @@ -92,8 +92,8 @@ - {% elif not section.writable %} -
+ {% elif not files %} +
{% trans "В этой папке пока нет файлов." %}
{% endif %}