diff --git a/management/daemon_reports.py b/management/daemon_reports.py index 26927900..fcebffcb 100644 --- a/management/daemon_reports.py +++ b/management/daemon_reports.py @@ -14,7 +14,7 @@ from functools import wraps from reporting.capture.db.SqliteConnFactory import SqliteConnFactory import reporting.uidata as uidata -from mailconfig import get_mail_users +from mailconfig import ( get_mail_users, validate_email ) def add_reports(app, env, authorized_personnel_only): @@ -178,7 +178,7 @@ def add_reports(app, env, authorized_personnel_only): r = subprocess.run(["systemctl", "reload", "miabldap-capture"]) if r.returncode != 0: - log.warning('systemctl reload faild for miabldap-capture: code=%s', r.returncode) + log.warning('systemctl reload failed for miabldap-capture: code=%s', r.returncode) else: # wait a sec for daemon to pick up new config # TODO: monitor runtime config for mtime change @@ -214,3 +214,41 @@ def add_reports(app, env, authorized_personnel_only): return jsonify(uidata.capture_db_stats(conn)) finally: db_conn_factory.close(conn) + + @app.route('/reports/uidata/message-headers', methods=['POST']) + @authorized_personnel_only + @json_payload + def get_message_headers(payload): + try: + user_id = payload['user_id'] + lmtp_id = payload['lmtp_id'] + except KeyError: + return ('invalid request', 400) + + if not validate_email(user_id, mode="user"): + return ('invalid email address', 400) + + r = subprocess.run( + [ + "/usr/bin/doveadm", + "fetch", + "-u",user_id, + "hdr", + "HEADER","received","LMTP id " + lmtp_id + ], + encoding="utf8", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + if r.returncode != 0: + log.error('retrieving message headers failed, code=%s, lmtp_id=%s, user_id=%s, stderr=%s', r.returncode, lmtp_id, user_id, r.stderr) + return Response(r.stderr, status=400, mimetype='text/plain') + + else: + out = r.stdout.strip() + if out.startswith('hdr:\n'): + out = out[5:] + return Response(out, status=200, mimetype='text/plain') + + diff --git a/management/reporting/ui/capture-db-stats.js b/management/reporting/ui/capture-db-stats.js index b828dc5d..59d5a049 100644 --- a/management/reporting/ui/capture-db-stats.js +++ b/management/reporting/ui/capture-db-stats.js @@ -1,9 +1,14 @@ +import { BvTable, ConnectionDisposition, DateFormatter } from "./charting.js"; +import { spinner } from "../../ui-common/page-header.js"; - -Vue.component('capture-db-stats', { +export default Vue.component('capture-db-stats', { props: { }, + components: { + spinner, + }, + template:'
'+ ' diff --git a/management/reporting/ui/panel-user-activity.js b/management/reporting/ui/panel-user-activity.js index 7542b523..53ef6e54 100644 --- a/management/reporting/ui/panel-user-activity.js +++ b/management/reporting/ui/panel-user-activity.js @@ -2,7 +2,13 @@ details on the activity of a user */ -Vue.component('panel-user-activity', function(resolve, reject) { +import wbr_text from "./wbr-text.js"; +import message_headers_view from "./message_headers_view.js"; +import UserSettings from "./settings.js"; +import { MailBvTable, ConnectionDisposition } from "./charting.js"; + + +export default Vue.component('panel-user-activity', function(resolve, reject) { axios.get('reports/ui/panel-user-activity.html').then((response) => { resolve({ template: response.data, @@ -12,7 +18,8 @@ Vue.component('panel-user-activity', function(resolve, reject) { }, components: { - 'wbr-text': Vue.component('wbr-text'), + 'wbr-text': wbr_text, + 'message-headers-view': message_headers_view }, data: function() { @@ -30,6 +37,7 @@ Vue.component('panel-user-activity', function(resolve, reject) { sent_mail: null, received_mail: null, imap_details: null, + lmtp_id: null, /* for message headers modal */ all_users: [], disposition_formatter: ConnectionDisposition.formatter, }; @@ -145,7 +153,9 @@ Vue.component('panel-user-activity', function(resolve, reject) { 'postgrey_reason', 'postgrey_delay', 'spam_score', - 'orig_to' + 'orig_to', + 'message_id', + 'lmtp_id', ]); // combine fields 'envelope_from' and 'sasl_username' var f = this.received_mail.combine_fields( @@ -286,6 +296,15 @@ Vue.component('panel-user-activity', function(resolve, reject) { row_clicked: function(item, index, event) { item._showDetails = ! item._showDetails; }, + + show_message_headers: function(lmtp_id) { + // set the lmtp_id that component message-headers-view + // searches for + this.lmtp_id = lmtp_id; + + // show the modal dialog + this.$refs.message_headers_modal.show(); + } } diff --git a/management/reporting/ui/reports-page-header.js b/management/reporting/ui/reports-page-header.js index 538b6fc6..2780387f 100644 --- a/management/reporting/ui/reports-page-header.js +++ b/management/reporting/ui/reports-page-header.js @@ -1,10 +1,12 @@ -Vue.component('reports-page-header', { +import page_header from '../../ui-common/page-header.js'; + +export default Vue.component('reports-page-header', { props: { loading_counter: { type:Number, required:true }, }, components: { - 'page-header': Vue.component('page-header'), + 'page-header': page_header, }, template: diff --git a/management/reporting/ui/settings.js b/management/reporting/ui/settings.js index 297e7d57..d543d473 100644 --- a/management/reporting/ui/settings.js +++ b/management/reporting/ui/settings.js @@ -1,6 +1,9 @@ +import { ValueError } from '../../ui-common/exceptions.js'; + + window.miabldap = window.miabldap || {}; -class CaptureConfig { +export class CaptureConfig { static get() { return axios.get('/reports/capture/config').then(response => { var cc = new CaptureConfig(); @@ -11,7 +14,7 @@ class CaptureConfig { }; -class UserSettings { +export default class UserSettings { static load() { if (window.miabldap.user_settings) { return Promise.resolve(window.miabldap.user_settings); diff --git a/management/reporting/ui/wbr-text.js b/management/reporting/ui/wbr-text.js index e661681a..6727d035 100644 --- a/management/reporting/ui/wbr-text.js +++ b/management/reporting/ui/wbr-text.js @@ -11,7 +11,7 @@ * browser to wrap at any character of the text. */ -Vue.component('wbr-text', { +export default Vue.component('wbr-text', { props: { text: { type:String, required: true }, break_chars: { type:String, default:'@_.,:+=' }, diff --git a/management/reporting/uidata/user_activity.2.sql b/management/reporting/uidata/user_activity.2.sql index 45614707..3f1f3004 100644 --- a/management/reporting/uidata/user_activity.2.sql +++ b/management/reporting/uidata/user_activity.2.sql @@ -7,9 +7,9 @@ connect_time, mta_connection.service AS service, sasl_username, disposition, remote_host, remote_ip, -- mta_accept envelope_from, spf_result, dkim_result, dkim_reason, dmarc_result, dmarc_reason, -failure_info, +message_id, failure_info, -- mta_delivery -postgrey_result, postgrey_reason, postgrey_delay, spam_score, spam_result, message_size, orig_to +postgrey_result, postgrey_reason, postgrey_delay, spam_score, spam_result, message_size, orig_to, delivery_info FROM mta_accept JOIN mta_connection ON mta_accept.mta_conn_id = mta_connection.mta_conn_id JOIN mta_delivery ON mta_accept.mta_accept_id = mta_delivery.mta_accept_id diff --git a/management/reporting/uidata/user_activity.py b/management/reporting/uidata/user_activity.py index ed135ba1..31221466 100644 --- a/management/reporting/uidata/user_activity.py +++ b/management/reporting/uidata/user_activity.py @@ -124,6 +124,7 @@ def user_activity(conn, args): 'dkim_reason', 'dmarc_result', 'dmarc_reason', + 'message_id', 'failure_info', # mta_delivery @@ -134,6 +135,7 @@ def user_activity(conn, args): 'spam_score', 'spam_result', 'message_size', + 'lmtp_id', ], 'field_types': [ { 'type':'datetime', 'format': '%Y-%m-%d %H:%M:%S' },# connect_time @@ -148,6 +150,7 @@ def user_activity(conn, args): 'text/plain', # dkim_result 'text/plain', # dmarc_result 'text/plain', # dmarc_reason + 'text/plain', # message_id 'text/plain', # failure_info 'text/email', # orig_to 'text/plain', # postgrey_result @@ -156,6 +159,7 @@ def user_activity(conn, args): { 'type':'decimal', 'places':2 }, # spam_score 'text/plain', # spam_result 'number/size', # message_size + 'text/plain', # lmtp_id ], 'items': [] } @@ -167,7 +171,31 @@ def user_activity(conn, args): }): v = [] for key in received_mail['fields']: - v.append(row[key]) + if key == 'lmtp_id': + # Extract the LMTP ID from delivery info, which looks + # like: + # + # "250 2.0.0 oPHmBDvTaWA7UwAAlWWVsw + # Saved" + # + # When we know the LMTP ID, we can get the message + # headers using doveadm, like this: + # + # "/usr/bin/doveadm fetch -u "user@domain.tld" hdr + # HEADER received "LMTP id oPHmBDvTaWA7UwAAlWWVsw" + # + delivery_info = row['delivery_info'] + valid = False + if delivery_info: + parts = delivery_info.split(' ') + if parts[0]=='250' and parts[1]=='2.0.0': + v.append(parts[-2]) + valid = True + if not valid: + v.append(None) + + else: + v.append(row[key]) received_mail['items'].append(v) diff --git a/management/ui-common/authentication.js b/management/ui-common/authentication.js index 90dbeb7d..b7e0d940 100644 --- a/management/ui-common/authentication.js +++ b/management/ui-common/authentication.js @@ -1,4 +1,7 @@ -class Me { +import { AuthenticationError } from './exceptions.js'; + + +export class Me { /* construct with return value from GET /me */ constructor(me) { Object.assign(this, me); @@ -18,7 +21,7 @@ class Me { * axios interceptors for authentication */ -function init_axios_interceptors() { +export function init_authentication_interceptors() { // requests: attach non-session based auth (admin panel) axios.interceptors.request.use(request => { @@ -38,7 +41,8 @@ function init_axios_interceptors() { }); - // reponses: redirect on authorization failure + // reponses: handle authorization failures by throwing exceptions + // users should catch AuthenticationError exceptions axios.interceptors.response.use( response => { if (response.data && diff --git a/management/ui-common/exceptions.js b/management/ui-common/exceptions.js index 2ff8588d..c4396c73 100644 --- a/management/ui-common/exceptions.js +++ b/management/ui-common/exceptions.js @@ -1,13 +1,13 @@ -class ValueError extends Error { +export class ValueError extends Error { constructor(msg) { super(msg); } }; -class AssertionError extends Error { +export class AssertionError extends Error { } -class AuthenticationError extends Error { +export class AuthenticationError extends Error { constructor(caused_by_error, msg, response) { super(msg); this.caused_by = caused_by_error; diff --git a/management/ui-common/page-header.js b/management/ui-common/page-header.js index 2374cb0d..a1b9a19e 100644 --- a/management/ui-common/page-header.js +++ b/management/ui-common/page-header.js @@ -1,8 +1,8 @@ -Vue.component('spinner', { +var spinner = Vue.component('spinner', { template: '' }); -Vue.component('page-header', function(resolve, reject) { +var header = Vue.component('page-header', function(resolve, reject) { axios.get('ui-common/page-header.html').then((response) => { resolve({ props: { @@ -17,3 +17,5 @@ Vue.component('page-header', function(resolve, reject) { }); }); + +export { spinner, header as default }; diff --git a/management/ui-common/page-layout.js b/management/ui-common/page-layout.js index 7af06a39..974edc97 100644 --- a/management/ui-common/page-layout.js +++ b/management/ui-common/page-layout.js @@ -1,4 +1,4 @@ -Vue.component('page-layout', function(resolve, reject) { +export default Vue.component('page-layout', function(resolve, reject) { axios.get('ui-common/page-layout.html').then((response) => { resolve({ template: response.data,