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
+66
View File
@@ -18,6 +18,7 @@ from backend.app.services.email_service import (
send_verification_email,
smtp_configured,
)
from backend.app.services.inbound_email_service import get_imap_settings, imap_configured, save_imap_settings
from backend.app.services.email_verification import create_verification_token
from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes
from backend.app.services.secret_store import encrypt_secret
@@ -63,6 +64,16 @@ class AdminThemesUpdate(BaseModel):
themes: list[str]
class AdminImapUpdate(BaseModel):
imap_host: str
imap_port: int = 993
imap_username: str = ''
imap_password: str = ''
imap_mailbox: str = 'INBOX'
imap_use_ssl: bool = True
imap_poll_interval_seconds: int = 60
def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None) -> dict:
smtp_host = payload.smtp_host.strip()
smtp_from = payload.smtp_from.strip()
@@ -80,6 +91,27 @@ def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None)
}
def validate_imap_values(payload: AdminImapUpdate, current: dict | None = None) -> dict:
imap_host = payload.imap_host.strip()
imap_username = payload.imap_username.strip()
imap_mailbox = payload.imap_mailbox.strip() or 'INBOX'
if not imap_host or not imap_username or not imap_mailbox:
raise HTTPException(status_code=422, detail='IMAP host, username, and mailbox are required')
if not 1 <= payload.imap_port <= 65535:
raise HTTPException(status_code=422, detail='IMAP port must be between 1 and 65535')
if payload.imap_poll_interval_seconds < 5:
raise HTTPException(status_code=422, detail='IMAP poll interval must be at least 5 seconds')
return {
'imap_host': imap_host,
'imap_port': payload.imap_port,
'imap_username': imap_username,
'imap_password': payload.imap_password or (current or {}).get('imap_password', ''),
'imap_mailbox': imap_mailbox,
'imap_use_ssl': payload.imap_use_ssl,
'imap_poll_interval_seconds': payload.imap_poll_interval_seconds,
}
def public_user(row):
return {
'id': row['id'],
@@ -166,11 +198,29 @@ def public_smtp_settings(values: dict) -> dict:
}
def public_imap_settings(values: dict) -> dict:
return {
'imap_host': values['imap_host'],
'imap_port': values['imap_port'],
'imap_username': values['imap_username'],
'imap_mailbox': values['imap_mailbox'],
'imap_use_ssl': values['imap_use_ssl'],
'imap_poll_interval_seconds': values['imap_poll_interval_seconds'],
'password_configured': bool(values['imap_password']),
'configured': imap_configured(values),
}
@router.get('/smtp')
def get_admin_smtp_settings(_: dict = Depends(require_admin)):
return public_smtp_settings(get_smtp_settings())
@router.get('/imap')
def get_admin_imap_settings(_: dict = Depends(require_admin)):
return public_imap_settings(get_imap_settings())
@router.get('/themes')
def get_admin_themes(_: dict = Depends(require_admin)):
return {'themes': THEMES, 'enabled': get_enabled_themes()}
@@ -195,6 +245,22 @@ def update_admin_smtp_settings(payload: AdminSmtpUpdate, current_user: dict = De
return public_smtp_settings(values)
@router.put('/imap')
def update_admin_imap_settings(payload: AdminImapUpdate, current_user: dict = Depends(require_admin)):
current = get_imap_settings()
values = validate_imap_values(payload, current)
save_imap_settings(values)
record_audit_event(current_user['id'], 'imap_settings_updated', 'application', details={
'host': values['imap_host'],
'port': values['imap_port'],
'username': values['imap_username'],
'mailbox': values['imap_mailbox'],
'ssl': values['imap_use_ssl'],
'poll_interval_seconds': values['imap_poll_interval_seconds'],
})
return public_imap_settings(values)
@router.post('/smtp/test')
def validate_admin_smtp(payload: AdminSmtpUpdate, request: Request, current_user: dict = Depends(require_admin)):
values = validate_smtp_values(payload, get_smtp_settings())
+7
View File
@@ -47,6 +47,13 @@ class Settings:
smtp_password: str = os.getenv('LINKLOG_SMTP_PASSWORD', '')
smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog <no-reply@localhost>')
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
imap_host: str = os.getenv('LINKLOG_IMAP_HOST', '')
imap_port: int = int(os.getenv('LINKLOG_IMAP_PORT', '993'))
imap_username: str = os.getenv('LINKLOG_IMAP_USERNAME', '')
imap_password: str = os.getenv('LINKLOG_IMAP_PASSWORD', '')
imap_mailbox: str = os.getenv('LINKLOG_IMAP_MAILBOX', 'INBOX')
imap_use_ssl: bool = os.getenv('LINKLOG_IMAP_USE_SSL', 'true').lower() in {'1', 'true', 'yes'}
imap_poll_interval_seconds: int = int(os.getenv('LINKLOG_IMAP_POLL_INTERVAL_SECONDS', '60'))
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
+16
View File
@@ -259,6 +259,22 @@ CREATE TABLE IF NOT EXISTS security_audit_events (
);
CREATE INDEX IF NOT EXISTS idx_security_audit_events_created_at ON security_audit_events(created_at);
CREATE INDEX IF NOT EXISTS idx_security_audit_events_actor_id ON security_audit_events(actor_id);
'''),
(18, '''
CREATE TABLE IF NOT EXISTS inbound_email_messages (
mailbox TEXT NOT NULL,
uid TEXT NOT NULL,
message_id TEXT,
user_id TEXT,
link_id TEXT,
status TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (mailbox, uid),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_inbound_email_messages_message_id ON inbound_email_messages(message_id);
''')
]
+22 -1
View File
@@ -1,8 +1,10 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import logging
from threading import Event, Thread
from uuid import uuid4
from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
@@ -20,12 +22,31 @@ from backend.app.api.setup import has_administrator
from backend.app.api.user_config import router as user_config_router
from backend.app.core.config import settings, validate_configuration
from backend.app.database import AVATARS_DIR
from backend.app.services.inbound_email_service import run_imap_polling
from backend.app.services.link_service import get_public_profile
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
validate_configuration(settings)
app = FastAPI(title='LinkLog API', version=settings.version)
@asynccontextmanager
async def lifespan(_: FastAPI):
stop_event = Event()
worker = Thread(
target=run_imap_polling,
args=(stop_event, logging.getLogger('backend.app.services.inbound_email_service')),
name='linklog-imap-poller',
daemon=True,
)
worker.start()
try:
yield
finally:
stop_event.set()
worker.join(timeout=2)
app = FastAPI(title='LinkLog API', version=settings.version, lifespan=lifespan)
@app.middleware('http')
@@ -0,0 +1,308 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from email import policy
from email.header import decode_header, make_header
from email.parser import BytesParser
from email.utils import getaddresses
from html.parser import HTMLParser
import imaplib
import json
import logging
import re
from threading import Event
from backend.app.core.config import settings
from backend.app.database import get_connection
from backend.app.services.link_service import create_link, normalize_tags
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
URL_PATTERN = re.compile(r'https?://[^\s<>()"\']+')
HASHTAG_PATTERN = re.compile(r'(?<!\w)#([A-Za-z0-9][\w-]*)')
class _HTMLTextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.text_parts: list[str] = []
self.hrefs: list[str] = []
def handle_data(self, data: str) -> None:
if data:
self.text_parts.append(data)
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag.lower() != 'a':
return
for name, value in attrs:
if name.lower() == 'href' and value:
self.hrefs.append(value)
def _decode_header_value(value: str | None) -> str:
if not value:
return ''
return str(make_header(decode_header(value))).strip()
def _extract_message_text(message) -> tuple[str, list[str]]:
plain_parts: list[str] = []
html_parts: list[str] = []
html_hrefs: list[str] = []
parts = message.walk() if message.is_multipart() else [message]
for part in parts:
if part.get_content_maintype() == 'multipart':
continue
content_type = part.get_content_type()
try:
content = part.get_content()
except (LookupError, UnicodeDecodeError):
continue
if not isinstance(content, str):
continue
if content_type == 'text/plain':
plain_parts.append(content)
elif content_type == 'text/html':
parser = _HTMLTextExtractor()
parser.feed(content)
html_parts.append(' '.join(parser.text_parts))
html_hrefs.extend(parser.hrefs)
text = '\n\n'.join(part.strip() for part in (plain_parts or html_parts) if part.strip())
urls = list(dict.fromkeys([*html_hrefs, *URL_PATTERN.findall(text)]))
return text, urls
def _extract_comment(text: str) -> str:
without_links = URL_PATTERN.sub(' ', text)
without_tags = HASHTAG_PATTERN.sub(' ', without_links)
lines = []
for raw_line in without_tags.splitlines():
line = ' '.join(raw_line.split()).strip()
if line:
lines.append(line)
return '\n'.join(lines)
def _extract_tags(*values: str) -> list[str]:
return normalize_tags([match.group(0) for value in values for match in HASHTAG_PATTERN.finditer(value or '')])
def _resolve_sender_email(message) -> str:
addresses = getaddresses(message.get_all('from', []))
for _, email in addresses:
if email:
return email.strip().lower()
return ''
def parse_incoming_email(raw_message: bytes) -> dict:
message = BytesParser(policy=policy.default).parsebytes(raw_message)
subject = _decode_header_value(message.get('Subject'))
text, urls = _extract_message_text(message)
subject_without_tags = HASHTAG_PATTERN.sub(' ', subject)
subject_without_link = URL_PATTERN.sub(' ', subject_without_tags)
title = ' '.join(subject_without_link.split()).strip()
tags = _extract_tags(subject, text)
return {
'message_id': _decode_header_value(message.get('Message-ID')),
'from_email': _resolve_sender_email(message),
'subject': subject,
'title': title,
'comment': _extract_comment(text),
'tags': tags,
'url': urls[0] if urls else None,
}
def get_imap_settings() -> dict:
values = {
'imap_host': settings.imap_host,
'imap_port': settings.imap_port,
'imap_username': settings.imap_username,
'imap_password': settings.imap_password,
'imap_mailbox': settings.imap_mailbox,
'imap_use_ssl': settings.imap_use_ssl,
'imap_poll_interval_seconds': settings.imap_poll_interval_seconds,
}
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('imap',)).fetchone()
if row:
values.update(json.loads(row['value']))
values['imap_password'] = decrypt_secret(values['imap_password'])
return values
def save_imap_settings(values: dict) -> None:
stored_values = {
**values,
'imap_password': encrypt_secret(values['imap_password']),
}
with get_connection() as conn:
conn.execute(
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
('imap', json.dumps(stored_values)),
)
conn.commit()
def imap_configured(imap_values: dict | None = None) -> bool:
imap = imap_values or get_imap_settings()
return bool(imap['imap_host'] and imap['imap_username'] and imap['imap_password'] and imap['imap_mailbox'])
def _find_user_by_email(address: str) -> dict | None:
email = address.strip().lower()
if not email:
return None
with get_connection() as conn:
row = conn.execute(
'SELECT id, username FROM users WHERE lower(email) = ? AND email_verified = 1',
(email,),
).fetchone()
if row is None:
row = conn.execute(
'''SELECT users.id, users.username
FROM user_email_addresses
JOIN users ON users.id = user_email_addresses.user_id
WHERE lower(user_email_addresses.email) = ? AND user_email_addresses.verified = 1''',
(email,),
).fetchone()
return dict(row) if row else None
def _already_processed(mailbox: str, uid: str) -> bool:
with get_connection() as conn:
row = conn.execute(
'SELECT 1 FROM inbound_email_messages WHERE mailbox = ? AND uid = ? LIMIT 1',
(mailbox, uid),
).fetchone()
return row is not None
def _record_message(mailbox: str, uid: str, status: str, parsed: dict, user_id: str | None = None, link_id: str | None = None, details: dict | None = None) -> None:
payload = details or {}
with get_connection() as conn:
conn.execute(
'''INSERT INTO inbound_email_messages (mailbox, uid, message_id, user_id, link_id, status, details)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(mailbox, uid) DO UPDATE SET
message_id = excluded.message_id,
user_id = excluded.user_id,
link_id = excluded.link_id,
status = excluded.status,
details = excluded.details''',
(mailbox, uid, parsed.get('message_id'), user_id, link_id, status, json.dumps(payload)),
)
conn.commit()
def ingest_message(mailbox: str, uid: str, raw_message: bytes) -> dict:
if _already_processed(mailbox, uid):
return {'status': 'duplicate', 'mailbox': mailbox, 'uid': uid}
parsed = parse_incoming_email(raw_message)
sender = parsed['from_email']
if not sender:
_record_message(mailbox, uid, 'ignored_missing_sender', parsed)
return {'status': 'ignored_missing_sender', 'mailbox': mailbox, 'uid': uid}
user = _find_user_by_email(sender)
if user is None:
_record_message(mailbox, uid, 'ignored_unknown_sender', parsed, details={'from_email': sender})
return {'status': 'ignored_unknown_sender', 'mailbox': mailbox, 'uid': uid, 'from_email': sender}
if not parsed['url']:
_record_message(mailbox, uid, 'ignored_no_link', parsed, user_id=user['id'])
return {'status': 'ignored_no_link', 'mailbox': mailbox, 'uid': uid, 'user_id': user['id']}
title = parsed['title'] or parsed['url']
record = create_link(user['id'], title, parsed['url'], parsed['comment'], None, parsed['tags'])
_record_message(mailbox, uid, 'stored', parsed, user_id=user['id'], link_id=record['id'])
return {
'status': 'stored',
'mailbox': mailbox,
'uid': uid,
'user_id': user['id'],
'link_id': record['id'],
'link': record,
}
def _open_imap_client(imap_values: dict):
client_class = imaplib.IMAP4_SSL if imap_values['imap_use_ssl'] else imaplib.IMAP4
return client_class(imap_values['imap_host'], imap_values['imap_port'])
def _fetch_message_bytes(response_data) -> bytes | None:
for item in response_data or []:
if isinstance(item, tuple) and len(item) > 1 and isinstance(item[1], (bytes, bytearray)):
return bytes(item[1])
return None
def poll_inbox_once(imap_values: dict | None = None, client_factory=None) -> dict:
values = imap_values or get_imap_settings()
if not imap_configured(values):
return {'status': 'not_configured', 'processed': 0, 'stored': 0, 'ignored': 0}
mailbox = values['imap_mailbox']
client = (client_factory or _open_imap_client)(values)
processed = 0
stored = 0
ignored = 0
try:
client.login(values['imap_username'], values['imap_password'])
status, _ = client.select(mailbox)
if status != 'OK':
raise RuntimeError(f'Could not select IMAP mailbox {mailbox}')
status, data = client.uid('search', None, 'UNSEEN')
if status != 'OK':
raise RuntimeError('Could not list unseen IMAP messages')
raw_uids = data[0].split() if data and data[0] else []
for uid in raw_uids:
uid_value = uid.decode('utf-8') if isinstance(uid, bytes) else str(uid)
status, message_data = client.uid('fetch', uid, '(BODY.PEEK[])')
if status != 'OK':
raise RuntimeError(f'Could not fetch IMAP message {uid_value}')
raw_message = _fetch_message_bytes(message_data)
if raw_message is None:
continue
result = ingest_message(mailbox, uid_value, raw_message)
processed += 1
if result['status'] == 'stored':
stored += 1
else:
ignored += 1
if result['status'] in {
'stored', 'duplicate', 'ignored_missing_sender', 'ignored_unknown_sender', 'ignored_no_link',
}:
client.uid('store', uid, '+FLAGS', '(\\Seen)')
return {'status': 'ok', 'processed': processed, 'stored': stored, 'ignored': ignored}
finally:
try:
client.logout()
except Exception:
pass
def run_imap_polling(stop_event: Event, logger: logging.Logger | None = None) -> None:
active_logger = logger or logging.getLogger(__name__)
while not stop_event.is_set():
values = get_imap_settings()
interval = max(5, int(values.get('imap_poll_interval_seconds', settings.imap_poll_interval_seconds or 60)))
if not imap_configured(values):
stop_event.wait(interval)
continue
try:
summary = poll_inbox_once(values)
if summary['processed']:
active_logger.info(
'IMAP poll processed=%s stored=%s ignored=%s mailbox=%s',
summary['processed'], summary['stored'], summary['ignored'], values['imap_mailbox'],
)
except Exception as error:
active_logger.warning('IMAP polling failed mailbox=%s error=%s', values.get('imap_mailbox'), error)
stop_event.wait(interval)