Email client fix

This commit is contained in:
2026-09-11 14:27:39 +03:00
parent 166a0bea71
commit f061c8f95b
4 changed files with 122 additions and 3 deletions
+17 -1
View File
@@ -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]:
+32
View File
@@ -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 <boss@aks-apteka.com.ua>',
'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')
+41 -2
View File
@@ -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'
+32
View File
@@ -139,6 +139,38 @@
</div>
</div>
{% endif %}
{% if send_success %}
<div style="margin-top:1rem; padding:0.65rem 0.85rem; border-radius:0.5rem; background:color-mix(in srgb, hsl(152 55% 45%) 12%, transparent); border:1px solid color-mix(in srgb, hsl(152 55% 45%) 35%, transparent); font-size:0.8125rem; color:hsl(152 55% 30%);">
Відповідь надіслано.
</div>
{% endif %}
{% if send_errors %}
<div style="margin-top:1rem; padding:0.65rem 0.85rem; border-radius:0.5rem; background:color-mix(in srgb, var(--color-destructive) 8%, transparent); border:1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); font-size:0.8125rem; color:var(--color-destructive);">
{% for err in send_errors %}<div>{{ err }}</div>{% endfor %}
</div>
{% endif %}
{% if message.reply_to %}
<form method="post" action="/email/send/"
hx-post="/email/send/" hx-target="#main-content" hx-indicator="#loader"
style="margin-top:1.25rem; padding-top:1rem; border-top:1px solid var(--color-border); display:flex; flex-direction:column; gap:0.5rem;">
{% csrf_token %}
<input type="hidden" name="to" value="{{ message.reply_to }}">
<input type="hidden" name="subject" value="{{ message.reply_subject }}">
<input type="hidden" name="reply_uid" value="{{ message.uid }}">
<input type="hidden" name="reply_folder" value="{{ active_folder }}">
<div style="font-size:0.8125rem; color:var(--color-muted-foreground);">
Відповідь для <strong style="color:var(--color-foreground);">{{ message.reply_to }}</strong>
</div>
<textarea name="body" required rows="5" class="input"
placeholder="Напишіть відповідь…"
style="width:100%; resize:vertical; min-height:6.5rem; font-size:0.875rem;">{{ form_body|default:'' }}</textarea>
<div>
<button type="submit" class="btn btn-primary" style="font-size:0.8125rem;">Надіслати відповідь</button>
</div>
</form>
{% endif %}
</div>
{% elif view == 'read' and read_error %}