aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2023-07-25 22:23:47 +0200
committerHugo Hörnquist <hugo@lysator.liu.se>2023-07-25 22:23:47 +0200
commit92fa15bd1938bfa852f27e24cbe3731493e20c7b (patch)
tree8369d7b57038326d781519f08cbee9a8f7381bae
parentAdd entry point and README. (diff)
downloadmu4web-92fa15bd1938bfa852f27e24cbe3731493e20c7b.tar.gz
mu4web-92fa15bd1938bfa852f27e24cbe3731493e20c7b.tar.xz
Move html components to own module.
-rw-r--r--mu4web/components.py131
-rw-r--r--mu4web/main.py591
2 files changed, 370 insertions, 352 deletions
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 <head>."""
+ 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 <head>."""
- 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 <html/> 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 <html/> 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():