Email client fix
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -125,6 +125,20 @@
|
||||
<div style="border-top:1px solid var(--color-border); padding-top:1rem; font-size:0.875rem; line-height:1.6; white-space:pre-wrap; overflow-wrap:anywhere;">
|
||||
{% if message.is_html %}{{ message.body|striptags|linebreaksbr }}{% else %}{{ message.body|linebreaksbr }}{% endif %}
|
||||
</div>
|
||||
{% if message.attachments %}
|
||||
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid var(--color-border);">
|
||||
<div style="font-size:0.75rem; font-weight:600; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.04em; margin-bottom:0.5rem;">Вкладення</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem;">
|
||||
{% for att in message.attachments %}
|
||||
<a href="/email/attachment/?folder={{ active_folder }}&uid={{ message.uid }}&i={{ att.index }}"
|
||||
style="display:inline-flex; align-items:center; gap:0.375rem; padding:0.375rem 0.625rem; border-radius:0.5rem; background:var(--color-muted); color:var(--color-foreground); text-decoration:none; font-size:0.8125rem;">
|
||||
📎 {{ att.name }}
|
||||
{% if att.size %}<span style="color:var(--color-muted-foreground); font-size:0.75rem;">{{ att.size|filesizeformat }}</span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif view == 'read' and read_error %}
|
||||
|
||||
@@ -92,8 +92,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif not section.writable %}
|
||||
<div style="padding:0.5rem 0; font-size:0.8125rem; color:var(--color-muted-foreground);">
|
||||
{% elif not files %}
|
||||
<div style="padding:0.35rem 0 0.65rem; font-size:0.8125rem; color:var(--color-muted-foreground);">
|
||||
{% trans "В этой папке пока нет файлов." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user