Email client fix
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user