incomming mail processing

This commit is contained in:
Olaf
2026-09-30 11:53:12 +02:00
parent b89d12769c
commit 0055cc197e
14 changed files with 656 additions and 1 deletions
+102
View File
@@ -15,6 +15,7 @@ from backend.app.main import app
from backend.app.core.config import settings
from backend.app.database import get_connection, hash_password
from backend.app.services.email_service import get_smtp_settings
from backend.app.services.inbound_email_service import get_imap_settings, poll_inbox_once
from backend.app.services.login_throttle import clear_login_failures
from backend.app.services.otp_service import current_code
from backend.app.services.password_reset import create_reset_token
@@ -147,6 +148,8 @@ def test_configuration_requires_authentication_and_admin_role():
assert client.get('/api/admin/users', headers=login_headers('bob')).status_code == 403
assert client.get('/api/admin/smtp').status_code == 401
assert client.get('/api/admin/smtp', headers=login_headers('bob')).status_code == 403
assert client.get('/api/admin/imap').status_code == 401
assert client.get('/api/admin/imap', headers=login_headers('bob')).status_code == 403
def test_admin_can_select_multiple_themes():
@@ -234,6 +237,105 @@ def test_admin_reports_smtp_validation_errors():
assert failed_validation.headers['X-Request-ID']
def test_admin_can_save_imap_settings():
headers = login_headers()
original = get_imap_settings()
with get_connection() as conn:
original_row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('imap',)).fetchone()
response = client.put('/api/admin/imap', headers=headers, json={
'imap_host': 'imap.example.com',
'imap_port': 993,
'imap_username': 'collector@example.com',
'imap_password': 'secret-imap-password',
'imap_mailbox': 'INBOX',
'imap_use_ssl': True,
'imap_poll_interval_seconds': 60,
})
assert response.status_code == 200
assert response.json()['imap_host'] == 'imap.example.com'
assert response.json()['password_configured'] is True
assert response.json()['configured'] is True
assert 'imap_password' not in response.json()
if original_row is None:
with get_connection() as conn:
conn.execute('DELETE FROM app_settings WHERE name = ?', ('imap',))
conn.commit()
else:
with get_connection() as conn:
conn.execute(
'UPDATE app_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?',
(original_row['value'], 'imap'),
)
conn.commit()
def test_poll_inbox_once_reads_unseen_messages_and_marks_them_seen():
class FakeImapClient:
def __init__(self):
self.actions = []
self.raw_message = (
b'From: Alice <alice@example.com>\n'
b'To: capture@linklog.example\n'
b'Subject: Polled inbox entry #AI\n'
b'Message-ID: <poll-1@example.com>\n'
b'Content-Type: text/plain; charset="utf-8"\n\n'
b'Collected from IMAP.\nhttps://example.com/polled\n'
)
def login(self, username, password):
self.actions.append(('login', username, password))
return 'OK', [b'Logged in']
def select(self, mailbox):
self.actions.append(('select', mailbox))
return 'OK', [b'1']
def uid(self, command, *args):
self.actions.append(('uid', command, *args))
if command == 'search':
return 'OK', [b'200']
if command == 'fetch':
return 'OK', [(b'200 (BODY[] {42}', self.raw_message), b')']
if command == 'store':
return 'OK', [b'200 (FLAGS (\\Seen))']
raise AssertionError(command)
def logout(self):
self.actions.append(('logout',))
return 'BYE', [b'Logged out']
summary = poll_inbox_once(
{
'imap_host': 'imap.example.com',
'imap_port': 993,
'imap_username': 'collector@example.com',
'imap_password': 'secret',
'imap_mailbox': 'INBOX',
'imap_use_ssl': True,
'imap_poll_interval_seconds': 60,
},
client_factory=lambda _: FakeImapClient(),
)
assert summary == {'status': 'ok', 'processed': 1, 'stored': 1, 'ignored': 0}
with get_connection() as conn:
link = conn.execute(
'SELECT title, url, comment, user_id FROM links WHERE url = ?',
('https://example.com/polled',),
).fetchone()
inbound = conn.execute(
'SELECT status FROM inbound_email_messages WHERE mailbox = ? AND uid = ?',
('INBOX', '200'),
).fetchone()
assert link['user_id'] == 'user-1'
assert link['title'] == 'Polled inbox entry'
assert link['comment'] == 'Collected from IMAP.'
assert inbound['status'] == 'stored'
def test_request_id_is_preserved_and_sensitive_error_text_is_not_returned():
headers = login_headers()
with get_connection() as conn:
@@ -0,0 +1,92 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from email.message import EmailMessage
from backend.app.database import get_connection
from backend.app.services.inbound_email_service import ingest_message, parse_incoming_email
def _raw_message(sender: str, subject: str, body: str, message_id: str) -> bytes:
message = EmailMessage()
message['From'] = sender
message['To'] = 'capture@linklog.example'
message['Subject'] = subject
message['Message-ID'] = message_id
message.set_content(body)
return message.as_bytes()
def test_parse_incoming_email_extracts_title_comment_tags_and_link():
parsed = parse_incoming_email(_raw_message(
'Alice <alice@example.com>',
'A useful article #AI',
'Interesting summary about the protocol.\nhttps://example.com/posts/1\n#Security',
'<msg-1@example.com>',
))
assert parsed['from_email'] == 'alice@example.com'
assert parsed['title'] == 'A useful article'
assert parsed['url'] == 'https://example.com/posts/1'
assert parsed['comment'] == 'Interesting summary about the protocol.'
assert parsed['tags'] == ['#AI', '#Security']
def test_ingest_message_stores_link_for_primary_email_sender():
result = ingest_message(
'INBOX',
'101',
_raw_message(
'Alice <alice@example.com>',
'Email sourced link #Fediverse',
'A comment from the inbox.\nhttps://example.com/inbox-link',
'<msg-2@example.com>',
),
)
assert result['status'] == 'stored'
with get_connection() as conn:
link = conn.execute(
'SELECT title, url, comment, user_id FROM links WHERE id = ?',
(result['link_id'],),
).fetchone()
tags = conn.execute(
'''SELECT tags.name
FROM tags
JOIN link_tags ON link_tags.tag_id = tags.id
WHERE link_tags.link_id = ?
ORDER BY tags.name''',
(result['link_id'],),
).fetchall()
audit = conn.execute(
'SELECT status FROM inbound_email_messages WHERE mailbox = ? AND uid = ?',
('INBOX', '101'),
).fetchone()
assert link['user_id'] == 'user-1'
assert link['title'] == 'Email sourced link'
assert link['url'] == 'https://example.com/inbox-link'
assert link['comment'] == 'A comment from the inbox.'
assert [row['name'] for row in tags] == ['#Fediverse']
assert audit['status'] == 'stored'
def test_ingest_message_accepts_verified_additional_address_and_ignores_duplicates():
with get_connection() as conn:
conn.execute(
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 1)',
('alt-email-test', 'user-1', 'alice-alt@example.com'),
)
conn.commit()
raw_message = _raw_message(
'Alice Alt <alice-alt@example.com>',
'Secondary sender #Links',
'https://example.com/secondary',
'<msg-3@example.com>',
)
first = ingest_message('INBOX', '102', raw_message)
second = ingest_message('INBOX', '102', raw_message)
assert first['status'] == 'stored'
assert second['status'] == 'duplicate'