From 92fa15bd1938bfa852f27e24cbe3731493e20c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= Date: Tue, 25 Jul 2023 22:23:47 +0200 Subject: Move html components to own module. --- mu4web/components.py | 131 ++++++++++++ mu4web/main.py | 591 +++++++++++++++++++++------------------------------ 2 files changed, 370 insertions(+), 352 deletions(-) create mode 100644 mu4web/components.py diff --git a/mu4web/components.py b/mu4web/components.py new file mode 100644 index 0000000..a441390 --- /dev/null +++ b/mu4web/components.py @@ -0,0 +1,131 @@ +"""Various HTML "components".""" + +from email.headerregistry import Address +from .html_render import HTML +from typing import Any, cast, Optional +from urllib.parse import urlencode +from email.message import EmailMessage + + +def format_email(addr: Address) -> list[HTML]: + """Format an email address suitable for the headers of the message view.""" + mail_addr = f'{addr.username}@{addr.domain}' + return [addr.display_name, ' <', mailto(mail_addr), '>'] + + +def mailto(addr: str) -> HTML: + """Construct a mailto anchor element.""" + return ('a', {'href': f'mailto:{addr}'}, addr) + + +def header_format(key: str, value: Any) -> HTML: + """Format email headers to HTML.""" + if key in ['to', 'cc', 'bcc']: + return ('ul', *[('li', *format_email(addr)) + for addr in value.addresses]) + elif key == 'from': + return format_email(value.addresses[0]) + elif key == 'in-reply-to': + # type(value) == email.headerregistry._UnstructuredHeader + id = str(value).strip("<>") + return ['<', ('a', {'href': '?' + urlencode({'id': id})}, id), '>'] + else: + # assert type(value) == str, f"Weird value in header {value!r}" + return str(value) + + +def attachement_tree(id: str, + mail: EmailMessage, + idx: int = 0) -> tuple[HTML, int]: + """Construct a tree of all attachements for the given mail.""" + ct = mail.get_content_type() + fn = mail.get_filename() + + children = [] + _idx = idx + for child in mail.iter_parts(): + tree, idx = attachement_tree(id, cast(EmailMessage, child), idx + 1) + children.append(tree) + + content: HTML + if children: + content = ('ul', *children) + else: + content = [] + + if fn: + body = f'{ct} {fn}' + else: + body = str(ct) + download = {} + if mail.get_content_type() == 'application/octet-stream': + download['download'] = mail.get_filename() or '' + return ('li', ('a', {'data-idx': str(_idx), + 'href': '/part?' + urlencode({'id': id, + 'idx': _idx}), + **download, + }, body), content), idx + + +def login_page(returnto: Optional[str] = None) -> HTML: + """HTML form for the login page.""" + return ('form', {'action': '/login', 'method': 'POST', 'class': 'loginform'}, + ('label', {'for': 'username'}, 'Användarnamn'), + ('input', {'id': 'username', 'name': 'username', 'placeholder': 'Användarnamn'}), + ('label', {'for': 'password'}, 'Lösenord'), + ('input', {'id': 'password', 'name': 'password', + 'placeholder': 'Lösenord', + 'type': 'password'}), + ('div', + ('input', {'id': 'remember', 'name': 'remember', 'type': 'checkbox'}), + ('label', {'for': 'remember'}, 'Kom ihåg mig')), + ('input', {'type': 'hidden', + 'name': 'returnto', + 'value': returnto}) + if returnto else [], + ('input', {'type': 'submit', 'value': 'Logga in'}), + ) + + +def user_info(username: str) -> HTML: + """ + Return user info for top bar. + + Includes the users name, and a button for logging out. + """ + return [('span', username), + ('form', {'action': '/logout', 'method': 'POST'}, + ('input', {'type': 'submit', 'value': 'Logga ut'}))] + + +def login_prompt() -> HTML: + """Return link to the login page.""" + return ('a', {'href': '/login'}, 'Logga in') + + +def flashed_messages(messages: list[str] | list[tuple[str, str]]) -> HTML: + """Return Flasks flashed messages, formatted as a list.""" + return ('ul', {'class': 'flashes'}, + *[('li', msg) for msg in messages]) + + +def include_stylesheet(path: str) -> HTML: + """Return HTML for including a stylesheet inside the .""" + return ('link', {'type': 'text/css', + 'rel': 'stylesheet', + 'href': path}) + + +def search_field(q: str) -> HTML: + """Build large search form for search page.""" + return ('form', {'id': 'searchform', + 'action': '/search', + 'method': 'GET'}, + ('label', {'for': 'search'}, + 'Sökförfrågan till Mu'), + ('input', {'id': 'search', + 'type': 'text', + 'placeholder': 'Sök...', + 'name': 'q', + 'value': q}), + ('input', {'type': 'Submit', 'value': 'Sök'})) diff --git a/mu4web/main.py b/mu4web/main.py index e56a0df..8a5fa62 100644 --- a/mu4web/main.py +++ b/mu4web/main.py @@ -1,7 +1,6 @@ """Web routes for mu4web.""" from email.message import EmailMessage -from email.headerregistry import Address from urllib.parse import urlencode from datetime import datetime import html @@ -35,6 +34,18 @@ from flask import ( flash, get_flashed_messages ) +from .components import ( + header_format, +) +from .components import ( + include_stylesheet, + flashed_messages, + login_prompt, + user_info, + search_field, + attachement_tree, + login_page, +) # # A few operations depend on the index of attachements. These index @@ -45,360 +56,9 @@ from flask import ( login_manager = LoginManager() -def mailto(addr: str) -> HTML: - """Construct a mailto anchor element.""" - return ('a', {'href': f'mailto:{addr}'}, addr) - - -def format_email(addr: Address) -> list[HTML]: - """Format an email address suitable for the headers of the message view.""" - mail_addr = f'{addr.username}@{addr.domain}' - return [addr.display_name, ' <', mailto(mail_addr), '>'] - - -def header_format(key: str, value: Any) -> HTML: - """Format email headers to HTML.""" - if key in ['to', 'cc', 'bcc']: - return ('ul', *[('li', *format_email(addr)) - for addr in value.addresses]) - elif key == 'from': - return format_email(value.addresses[0]) - elif key == 'in-reply-to': - # type(value) == email.headerregistry._UnstructuredHeader - id = str(value).strip("<>") - return ['<', ('a', {'href': '?' + urlencode({'id': id})}, id), '>'] - else: - # assert type(value) == str, f"Weird value in header {value!r}" - return str(value) - - -def attachement_tree(id: str, - mail: EmailMessage, - idx: int = 0) -> tuple[HTML, int]: - """Construct a tree of all attachements for the given mail.""" - ct = mail.get_content_type() - fn = mail.get_filename() - - children = [] - _idx = idx - for child in mail.iter_parts(): - tree, idx = attachement_tree(id, cast(EmailMessage, child), idx + 1) - children.append(tree) - - content: HTML - if children: - content = ('ul', *children) - else: - content = [] - - if fn: - body = f'{ct} {fn}' - else: - body = str(ct) - download = {} - if mail.get_content_type() == 'application/octet-stream': - download['download'] = mail.get_filename() or '' - return ('li', ('a', {'data-idx': str(_idx), - 'href': '/part?' + urlencode({'id': id, - 'idx': _idx}), - **download, - }, body), content), idx - # -------------------------------------------------- -def login_page(returnto: Optional[str] = None) -> HTML: - """HTML form for the login page.""" - return ('form', {'action': '/login', 'method': 'POST', 'class': 'loginform'}, - ('label', {'for': 'username'}, 'Användarnamn'), - ('input', {'id': 'username', 'name': 'username', 'placeholder': 'Användarnamn'}), - ('label', {'for': 'password'}, 'Lösenord'), - ('input', {'id': 'password', 'name': 'password', - 'placeholder': 'Lösenord', - 'type': 'password'}), - ('div', - ('input', {'id': 'remember', 'name': 'remember', 'type': 'checkbox'}), - ('label', {'for': 'remember'}, 'Kom ihåg mig')), - ('input', {'type': 'hidden', - 'name': 'returnto', - 'value': returnto}) - if returnto else [], - ('input', {'type': 'submit', 'value': 'Logga in'}), - ) - - -def user_info(username: str) -> HTML: - """ - Return user info for top bar. - - Includes the users name, and a button for logging out. - """ - return [('span', username), - ('form', {'action': '/logout', 'method': 'POST'}, - ('input', {'type': 'submit', 'value': 'Logga ut'}))] - - -def login_prompt() -> HTML: - """Return link to the login page.""" - return ('a', {'href': '/login'}, 'Logga in') - - -def flashed_messages() -> HTML: - """Return Flasks flashed messages, formatted as a list.""" - return ('ul', {'class': 'flashes'}, - *[('li', msg) for msg in get_flashed_messages()]) - - -def include_stylesheet(path: str) -> HTML: - """Return HTML for including a stylesheet inside the .""" - return ('link', {'type': 'text/css', - 'rel': 'stylesheet', - 'href': path}) - - -def page_base(title: Optional[str] = None, - body: HTML = []) -> HTML: - """ - Build base layout for almost all pages. - - The base contents of our html page, from the tag and down. - - :param title: - Local pagetitle, will be suffixed with site suffix. - - :param body: - Contents of the page. Will work without this, but the page - would lack any actual contents. - """ - if title: - full_title = f'{title} — Mu4Web' - else: - full_title = 'Mu4Web' - - return ('html', {'lang': 'sv'}, - ('head', - ('meta', {'charset': 'utf-8'}), - ('meta', {'name': 'viewport', - 'content': 'width=device-width, initial-scale=0.5'}), - ('title', full_title), - include_stylesheet(url_for('static', filename='style.css')), - ), - ('body', - ('nav', - ('menu', - ('li', ('h1', ('a', {'href': '/'}, 'Mu4Web'))), - ('hr',), - ('li', ('form', {'action': '/search', - 'method': 'GET'}, - ('input', {'type': 'text', - 'placeholder': 'Sök...', - 'name': 'q'}), - ('input', {'type': 'Submit', - 'value': 'Sök'}))), - ('li', - user_info(current_user.get_id()) - if current_user.is_authenticated else login_prompt()) - )), - ('main', - flashed_messages(), - body), - ('footer', - ('menu', - ('li', ('a', {'href': 'https://www.djcbsoftware.nl/code/mu/'}, 'mu')), - ('li', ('a', {'href': 'https://git.hornquist.se/mu4web'}, 'Source')), - )))) - - -def response_for(id: str, mail: EmailMessage) -> str: - """ - Build response page for an email or a tree. - - :param id: - The message id of the root message - :param mail: - Either the root component of a mail, or a sub-component of - type message/rfc822. - """ - # Setup headers - headers = {} - print("mail", mail) - for (key, value) in mail.items(): - headers[key.lower()] = value - - head = [] - for h in app.config['MESSAGE_HEADERS']: - if x := headers.get(h.lower()): - head += [('dt', h.title()), - ('dd', header_format(h.lower(), x))] - - all_heads = [] - for key, value in mail.items(): - all_heads += [('dt', key.title()), - ('dd', value)] - - full_headers = ('details', - ('summary', 'Alla mailhuvuden'), - ('dl', *all_heads)) - - # Setup title - if t := headers.get('subject'): - title = f'Mail — {t}' - else: - title = 'Mail' - - # Setup body - body: list[HTML] = [] - # Manual walk to preserve attachement index - for idx, at in enumerate(mail.walk()): - # body.append(('h2', at.get_content_type())) - if at.is_multipart(): - continue - elif at.get_content_type() == 'text/html': - # ct = at.get_content_type() - url = '/part?' + urlencode({'id': id, 'idx': idx}) - body.append(('iframe', {'src': url, - 'height': '300', - })) - elif at.get_content_type() == 'text/plain': - body.append(('pre', at.get_content())) - elif at.get_content_type() == 'application/octet-stream': - url = '/part?' + urlencode({'id': id, 'idx': idx}) - body.append(('a', {'href': url, - 'download': at.get_filename() or ''}, - at.get_filename() or at.get_content_type())) - else: - url = '/part?' + urlencode({'id': id, 'idx': idx}) - body.append(('a', {'href': url}, - at.get_filename() or at.get_content_type())) - - # Setup attachements - tree, idx = attachement_tree(id, mail) - - main_body: list[HTML] = [('dl', *head), - full_headers, - ('hr',), - ('main', body), - ('hr',), - ('a', {'href': '/raw?' + urlencode({'id': id})}, - 'Råa bitar'), - ('ul', tree), - ] - html_str = render_document(page_base(title=title, - body=main_body)) - - return html_str - - -def search_field(q: str) -> HTML: - """Build large search form for search page.""" - return ('form', {'id': 'searchform', - 'action': '/search', - 'method': 'GET'}, - ('label', {'for': 'search'}, - 'Sökförfrågan till Mu'), - ('input', {'id': 'search', - 'type': 'text', - 'placeholder': 'Sök...', - 'name': 'q', - 'value': q}), - ('input', {'type': 'Submit', 'value': 'Sök'})) - - -def search_result(q: str, by: Optional[str], direction: str) -> HTML: - """ - Search database for query, and build resulting HTML body. - - :param q: - Mu search query. - - :param by: - Parameter to sort by. - - :param direction: - Direction to sort results in. One of 'rising' or 'falling'. - """ - assert direction in ('rising', 'falling') - - # keys = ['from', 'to', 'subject', 'date', 'size', 'maildir', 'msgid'] - keys = ['from', 'to', 'subject', 'date'] - if not by: - by = app.config['DEFAULT_SORT_COLUMN'] - - rows = mu.search(q, by, direction == 'falling') - body: list[HTML] = [] - for row in rows: - rowdata: list[HTML] = [] - for key in keys: - data = row.get(key, None) - if data and key == 'date': - dt = datetime.fromtimestamp(int(data)) - data = dt.strftime('%Y-%m-%d %H:%M') - rowdata.append(['td', ('a', {'href': '/?id=' + row['msgid']}, data)]) - body.append(['tr', rowdata]) - - if len(rows) == 0: - return "Inga träffar" - else: - - heads: list[HTML] = [] - for m in keys: - link_body = m.title() - params = {'q': q, 'by': m} - if m == by: - link_body += ' ' - if direction == 'rising': - link_body += '▲' - params['direction'] = 'falling' - else: - link_body += '▼' - params['direction'] = 'rising' - heads.append(('th', ('a', {'href': '?' + urlencode(params)}, - link_body))) - - return ('div', - ('p', f"{len(rows)} träffar"), - ('table', - ('thead', - ('tr', - *heads - )), - ('tbody', body))) - - -def search_page(q: str, by: Optional[str], - direction: str) -> str: - """Return rendered HTML for search page.""" - main_body = [search_field(q)] - - # TODO pagination - # Mu handles the search without problem, but python is slow to - # build the table, and the browser has problem rendering it - if q: - main_body.append(search_result(q, by, direction)) - - return render_document(page_base(title='Sökning', - body=main_body)) - - -def index_page() -> str: - """Return rendered HTML for index page.""" - data = mu.info() - maildirs = find_maildirs(data['maildir'] + '/') - - entries = serialize_maildir(maildirs) - - rows = [] - for key, value in data.items(): - rows.append(('tr', - ('td', key), - ('td', value))) - body: HTML = [('div', ('table', ('tbody', rows))), - ('div', entries), - ] - return render_document(page_base(title='E-postindex', - body=body)) - - app = Flask(__name__, instance_relative_config=True) # Default configuration values @@ -642,6 +302,233 @@ class IMGParser(HTMLParser): assert False, 'Should never be reached' +def page_base(title: Optional[str] = None, + body: HTML = []) -> HTML: + """ + Build base layout for almost all pages. + + The base contents of our html page, from the tag and down. + + :param title: + Local pagetitle, will be suffixed with site suffix. + + :param body: + Contents of the page. Will work without this, but the page + would lack any actual contents. + """ + if title: + full_title = f'{title} — Mu4Web' + else: + full_title = 'Mu4Web' + + return ('html', {'lang': 'sv'}, + ('head', + ('meta', {'charset': 'utf-8'}), + ('meta', {'name': 'viewport', + 'content': 'width=device-width, initial-scale=0.5'}), + ('title', full_title), + include_stylesheet(url_for('static', filename='style.css')), + ), + ('body', + ('nav', + ('menu', + ('li', ('h1', ('a', {'href': '/'}, 'Mu4Web'))), + ('hr',), + ('li', ('form', {'action': '/search', + 'method': 'GET'}, + ('input', {'type': 'text', + 'placeholder': 'Sök...', + 'name': 'q'}), + ('input', {'type': 'Submit', + 'value': 'Sök'}))), + ('li', + user_info(current_user.get_id()) + if current_user.is_authenticated else login_prompt()) + )), + ('main', + flashed_messages(get_flashed_messages()), + body), + ('footer', + ('menu', + ('li', ('a', {'href': 'https://www.djcbsoftware.nl/code/mu/'}, 'mu')), + ('li', ('a', {'href': 'https://git.hornquist.se/mu4web'}, 'Source')), + )))) + + +def search_page(q: str, by: Optional[str], + direction: str) -> str: + """Return rendered HTML for search page.""" + main_body = [search_field(q)] + + # TODO pagination + # Mu handles the search without problem, but python is slow to + # build the table, and the browser has problem rendering it + if q: + main_body.append(search_result(q, by, direction)) + + return render_document(page_base(title='Sökning', + body=main_body)) + + +def index_page() -> str: + """Return rendered HTML for index page.""" + data = mu.info() + maildirs = find_maildirs(data['maildir'] + '/') + + entries = serialize_maildir(maildirs) + + rows = [] + for key, value in data.items(): + rows.append(('tr', + ('td', key), + ('td', value))) + body: HTML = [('div', ('table', ('tbody', rows))), + ('div', entries), + ] + return render_document(page_base(title='E-postindex', + body=body)) + + +def search_result(q: str, by: Optional[str], direction: str) -> HTML: + """ + Search database for query, and build resulting HTML body. + + :param q: + Mu search query. + + :param by: + Parameter to sort by. + + :param direction: + Direction to sort results in. One of 'rising' or 'falling'. + """ + assert direction in ('rising', 'falling') + + # keys = ['from', 'to', 'subject', 'date', 'size', 'maildir', 'msgid'] + keys = ['from', 'to', 'subject', 'date'] + if not by: + by = app.config['DEFAULT_SORT_COLUMN'] + + rows = mu.search(q, by, direction == 'falling') + body: list[HTML] = [] + for row in rows: + rowdata: list[HTML] = [] + for key in keys: + data = row.get(key, None) + if data and key == 'date': + dt = datetime.fromtimestamp(int(data)) + data = dt.strftime('%Y-%m-%d %H:%M') + rowdata.append(['td', ('a', {'href': '/?id=' + row['msgid']}, data)]) + body.append(['tr', rowdata]) + + if len(rows) == 0: + return "Inga träffar" + else: + + heads: list[HTML] = [] + for m in keys: + link_body = m.title() + params = {'q': q, 'by': m} + if m == by: + link_body += ' ' + if direction == 'rising': + link_body += '▲' + params['direction'] = 'falling' + else: + link_body += '▼' + params['direction'] = 'rising' + heads.append(('th', ('a', {'href': '?' + urlencode(params)}, + link_body))) + + return ('div', + ('p', f"{len(rows)} träffar"), + ('table', + ('thead', + ('tr', + *heads + )), + ('tbody', body))) + + +def response_for(id: str, mail: EmailMessage) -> str: + """ + Build response page for an email or a tree. + + :param id: + The message id of the root message + :param mail: + Either the root component of a mail, or a sub-component of + type message/rfc822. + """ + # Setup headers + headers = {} + for (key, value) in mail.items(): + headers[key.lower()] = value + + head = [] + for h in app.config['MESSAGE_HEADERS']: + if x := headers.get(h.lower()): + head += [('dt', h.title()), + ('dd', header_format(h.lower(), x))] + + all_heads = [] + for key, value in mail.items(): + all_heads += [('dt', key.title()), + ('dd', value)] + + full_headers = ('details', + ('summary', 'Alla mailhuvuden'), + ('dl', *all_heads)) + + # Setup title + if t := headers.get('subject'): + title = f'Mail — {t}' + else: + title = 'Mail' + + # Setup body + body: list[HTML] = [] + # Manual walk to preserve attachement index + for idx, at in enumerate(mail.walk()): + # body.append(('h2', at.get_content_type())) + if at.is_multipart(): + continue + elif at.get_content_type() == 'text/html': + # ct = at.get_content_type() + url = '/part?' + urlencode({'id': id, 'idx': idx}) + body.append(('iframe', {'src': url, + 'height': '300', + })) + elif at.get_content_type() == 'text/plain': + body.append(('pre', at.get_content())) + elif at.get_content_type() == 'application/octet-stream': + url = '/part?' + urlencode({'id': id, 'idx': idx}) + body.append(('a', {'href': url, + 'download': at.get_filename() or ''}, + at.get_filename() or at.get_content_type())) + else: + url = '/part?' + urlencode({'id': id, 'idx': idx}) + body.append(('a', {'href': url}, + at.get_filename() or at.get_content_type())) + + # Setup attachements + tree, idx = attachement_tree(id, mail) + + main_body: list[HTML] = [('dl', *head), + full_headers, + ('hr',), + ('main', body), + ('hr',), + ('a', {'href': '/raw?' + urlencode({'id': id})}, + 'Råa bitar'), + ('ul', tree), + ] + html_str = render_document(page_base(title=title, + body=main_body)) + + return html_str + + @app.route('/part') @login_required def attachement_part_page(): -- cgit v1.2.3