diff --git a/apps/mail_connector/imap_client.py b/apps/mail_connector/imap_client.py index d6a4c19..1459502 100644 --- a/apps/mail_connector/imap_client.py +++ b/apps/mail_connector/imap_client.py @@ -9,7 +9,7 @@ import ssl from dataclasses import dataclass from datetime import datetime from email.header import decode_header -from email.utils import parsedate_to_datetime +from email.utils import parseaddr, parsedate_to_datetime from apps.mail_connector.mail_settings import MailSettings @@ -107,6 +107,18 @@ def _iter_file_parts(msg: email.message.Message): index += 1 +def extract_reply_address(header: str) -> str: + _name, addr = parseaddr(header or '') + return (addr or '').strip() + + +def reply_subject(subject: str) -> str: + text = (subject or '').strip() + if text.lower().startswith('re:'): + return text + return f'Re: {text}' if text else 'Re:' + + def _attachment_summaries(msg: email.message.Message) -> list[dict]: items = [] for index, name, payload, _ctype in _iter_file_parts(msg): @@ -268,6 +280,10 @@ class ImapMailClient: ) ), 'attachments': _attachment_summaries(msg), + 'reply_to': extract_reply_address( + _decode_mime(msg.get('Reply-To')) or _decode_mime(msg.get('From')) + ), + 'reply_subject': reply_subject(_decode_mime(msg.get('Subject')) or ''), } def fetch_attachment(self, uid: str, folder_key: str, index: int) -> tuple[str, bytes, str]: diff --git a/apps/mail_connector/tests.py b/apps/mail_connector/tests.py index 88c8321..2405f31 100644 --- a/apps/mail_connector/tests.py +++ b/apps/mail_connector/tests.py @@ -227,6 +227,38 @@ class MailViewTests(TestCase): send_message.assert_called_once() self.assertContains(response, 'Лист відправлено') + @mock.patch('apps.mail_connector.views.send_message') + @mock.patch('apps.mail_connector.views.ImapMailClient') + def test_inline_reply_stays_on_message(self, client_cls, send_message): + mock_client = mock.Mock() + client_cls.return_value.__enter__.return_value = mock_client + client_cls.return_value.__exit__.return_value = False + mock_client.unread_count.return_value = 0 + mock_client.fetch_message.return_value = { + 'uid': '10', + 'subject': 'Тест', + 'sender': 'Boss ', + 'to': 'me@aks-apteka.com.ua', + 'date': '', + 'body': 'Привіт', + 'is_html': False, + 'attachments': [], + 'reply_to': 'boss@aks-apteka.com.ua', + 'reply_subject': 'Re: Тест', + } + set_client_mail_password(self.client, 'imap-secret') + response = self.client.post('/email/send/', { + 'to': 'boss@aks-apteka.com.ua', + 'subject': 'Re: Тест', + 'body': 'Дякую', + 'reply_uid': '10', + 'reply_folder': 'inbox', + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + send_message.assert_called_once() + self.assertContains(response, 'Відповідь надіслано') + self.assertContains(response, 'Надіслати відповідь') + @mock.patch('apps.mail_connector.views.ImapMailClient') def test_connect_invalid_password(self, client_cls): client_cls.return_value.__enter__.side_effect = MailConnectionError('AUTH failed') diff --git a/apps/mail_connector/views.py b/apps/mail_connector/views.py index b6fa2e9..9aeafc9 100644 --- a/apps/mail_connector/views.py +++ b/apps/mail_connector/views.py @@ -224,15 +224,47 @@ def email_send(request): address = mailbox_address(request.user) password = get_mail_password(request) - ctx = _base_context(request, compose=True, form_to=to_raw, form_cc=cc_raw, - form_subject=subject, form_body=body) + reply_uid = (request.POST.get('reply_uid') or '').strip() + reply_folder = request.POST.get('reply_folder') or 'inbox' + ctx = _base_context( + request, + compose=not bool(reply_uid), + form_to=to_raw, + form_cc=cc_raw, + form_subject=subject, + form_body=body, + active_folder=reply_folder if reply_uid else request.GET.get('folder', 'inbox'), + ) + + def _reload_reply(extra): + ctx['mail_state'] = 'ready' + ctx['view'] = 'read' + ctx['compose'] = False + ctx.update(extra) + starred = reply_folder == 'starred' + + def load_one(client): + msg = client.fetch_message(reply_uid, 'inbox' if starred else reply_folder) + unread = client.unread_count('inbox') + return msg, unread + + loaded, _err_key, err_msg = _with_imap(request, load_one) + if loaded: + ctx['message'], ctx['unread_count'] = loaded + else: + ctx['read_error'] = err_msg or 'Не вдалося відкрити лист' + return render_page(request, 'email.html', ctx) if not password: + if reply_uid: + return _reload_reply({'send_errors': ['Підключіть поштову скриньку.']}) ctx['send_errors'] = ['Підключіть поштову скриньку.'] ctx['mail_state'] = 'connect' return render_page(request, 'email.html', ctx) if errors: + if reply_uid: + return _reload_reply({'send_errors': errors}) ctx['send_errors'] = errors ctx['mail_state'] = 'ready' return render_page(request, 'email.html', ctx) @@ -250,14 +282,21 @@ def email_send(request): cc_addrs=cc_addrs, ) except MailConfigError as e: + if reply_uid: + return _reload_reply({'send_errors': [str(e)]}) ctx['send_errors'] = [str(e)] ctx['mail_state'] = 'ready' return render_page(request, 'email.html', ctx) except SmtpSendError as e: + if reply_uid: + return _reload_reply({'send_errors': [f'Помилка відправки: {e}']}) ctx['send_errors'] = [f'Помилка відправки: {e}'] ctx['mail_state'] = 'ready' return render_page(request, 'email.html', ctx) + if reply_uid: + return _reload_reply({'send_success': True, 'form_body': ''}) + ctx['mail_state'] = 'ready' ctx['send_success'] = True ctx['view'] = 'list' diff --git a/templates/pages/email.html b/templates/pages/email.html index 364ac20..2352935 100644 --- a/templates/pages/email.html +++ b/templates/pages/email.html @@ -139,6 +139,38 @@ {% endif %} + + {% if send_success %} +
+ Відповідь надіслано. +
+ {% endif %} + {% if send_errors %} +
+ {% for err in send_errors %}
{{ err }}
{% endfor %} +
+ {% endif %} + + {% if message.reply_to %} +
+ {% csrf_token %} + + + + +
+ Відповідь для {{ message.reply_to }} +
+ +
+ +
+
+ {% endif %} {% elif view == 'read' and read_error %}