]> git.proxmox.com Git - pmg-gui.git/commitdiff
close #1671: implement mobile UI for quarantine
authorDominik Csapak <d.csapak@proxmox.com>
Mon, 18 Feb 2019 12:50:50 +0000 (13:50 +0100)
committerDietmar Maurer <dietmar@proxmox.com>
Tue, 19 Feb 2019 09:04:19 +0000 (10:04 +0100)
this patch implements a UI for the Quarantine, designed to
be looked at on mobile phones

for this we use Framework7 instead of extjs, since it has much more
features and looks more native on phones

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
Makefile
css/ext6-pmg-mobile.css [new file with mode: 0644]
debian/control
js/Makefile
js/mobile/app.js [new file with mode: 0644]
js/mobile/component.js [new file with mode: 0644]
js/mobile/loginscreen.js [new file with mode: 0644]
js/mobile/mailview.js [new file with mode: 0644]
js/mobile/quarantineview.js [new file with mode: 0644]
js/mobile/utils.js [new file with mode: 0644]
pmg-mobile-index.html.tt [new file with mode: 0644]

index 045f273f4584acbf39158cebd6303f73c64971e9..52a16ff12fe0ce4d9c0d55a6a64510bc12d02474 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,7 @@ IMAGES=                               \
        logo-128.png            \
        proxmox_logo.png
 
-CSSFILES = ext6-pmg.css
+CSSFILES = ext6-pmg.css ext6-pmg-mobile.css
 
 all:
 
@@ -31,12 +31,17 @@ deb ${DEB}:
 js/pmgmanagerlib.js:
        make -C js pmgmanagerlib.js
 
-install: pmg-index.html.tt js/pmgmanagerlib.js
+js/pmgmanagerlib-mobile.js:
+       make -C js pmgmanagerlib-mobile.js
+
+install: pmg-index.html.tt js/pmgmanagerlib.js js/pmgmanagerlib-mobile.js
        install -d -m 755 ${WWWCSSDIR}
        install -d -m 755 ${WWWIMAGESDIR}
        install -d -m 755 ${WWWJSDIR}
        install -m 0644 pmg-index.html.tt ${WWWBASEDIR}
+       install -m 0644 pmg-mobile-index.html.tt ${WWWBASEDIR}
        install -m 0644 js/pmgmanagerlib.js ${WWWJSDIR}
+       install -m 0644 js/pmgmanagerlib-mobile.js ${WWWJSDIR}
        for i in ${IMAGES}; do install -m 0644 images/$$i ${WWWIMAGESDIR}; done
        for i in ${CSSFILES}; do install -m 0644 css/$$i ${WWWCSSDIR}; done
 
diff --git a/css/ext6-pmg-mobile.css b/css/ext6-pmg-mobile.css
new file mode 100644 (file)
index 0000000..adbd88b
--- /dev/null
@@ -0,0 +1,46 @@
+.item-title .item-header {
+    white-space: inherit;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.empty {
+    padding: 1em;
+    color: var(--f7-label-text-color);
+}
+
+img.logo {
+    padding: 0 10px;
+    vertical-align: middle;
+    height: 64px;
+}
+
+img.logo-navbar {
+    padding: 0 10px;
+    height: 32;
+}
+
+.settings-form {
+    position: absolute;
+    bottom: calc(var(--f7-fab-margin) + var(--f7-safe-area-bottom));
+    right: calc(var(--f7-fab-margin) + var(--f7-safe-area-right));
+    z-index: 1500;
+    width: 200px;
+    background-color: var(--f7-list-bg-color);
+}
+
+.button.subscription i.icon {
+    display: inline;
+}
+
+@media only screen and (max-width: 500px) {
+    .login-screen-title {
+       font-size: 6vw;
+    }
+}
+
+@media only screen and (min-width: 500px) {
+    .login-screen-title {
+       font-size: 32px;
+    }
+}
index a4e93ede2a27c721810f0ec016569c1453267fe0..74a3d84913074b17b16dd50c19ee8aca45ef25a3 100644 (file)
@@ -2,12 +2,20 @@ Source: pmg-gui
 Section: perl
 Priority: optional
 Maintainer: Proxmox Support Team <support@proxmox.com>
-Build-Depends: debhelper (>= 9), perl (>= 5.10.0-19), libtemplate-perl
+Build-Depends: debhelper (>= 9),
+               libtemplate-perl,
+               perl (>= 5.10.0-19),
 Standards-Version: 3.9.5
 Homepage: http://www.proxmox.com
 
 Package: pmg-gui
 Architecture: all
-Depends: ${perl:Depends}, libtemplate-perl, libjs-extjs (>= 6.0.1), fonts-font-awesome, pmg-i18n, proxmox-widget-toolkit
+Depends: fonts-font-awesome,
+         libjs-extjs (>= 6.0.1),
+         libjs-framework7,
+         libtemplate-perl,
+         pmg-i18n,
+         proxmox-widget-toolkit,
+         ${perl:Depends},
 Description: Proxmox Mail Gateway GUI
  Graphical user interface for Proxmox Mail Gateway.
index 882cbc4f0311e7897c6c6805861c9e41d9ddbc67..6f4d449beae8100bec8e37911d76ec1d6a54d8a4 100644 (file)
@@ -84,6 +84,15 @@ JSSRC=                                                       \
        SpamContextMenu.js                              \
        Application.js
 
+# caution: order is important
+MOBILESRC=                             \
+         mobile/component.js           \
+         mobile/loginscreen.js         \
+         mobile/mailview.js            \
+         mobile/quarantineview.js      \
+         mobile/utils.js               \
+         mobile/app.js                 \
+
 OnlineHelpInfo.js: /usr/bin/asciidoc-pmg
        /usr/bin/asciidoc-pmg scan-extjs ${JSSRC} >$@.tmp
        mv $@.tmp $@
@@ -95,12 +104,16 @@ pmgmanagerlib.js: OnlineHelpInfo.js ${JSSRC}
        cat OnlineHelpInfo.js ${JSSRC} >$@.tmp
        mv $@.tmp $@
 
-all: pmgmanagerlib.js
+pmgmanagerlib-mobile.js: ${MOBILESRC}
+       cat ${MOBILESRC} >$@.tmp
+       mv $@.tmp $@
+
+all: pmgmanagerlib.js pmgmanagerlib-mobile.js
 
 .PHONY: clean
 clean:
        find . -name '*~' -exec rm {} ';'       
-       rm -rf pmgmanagerlib.js OnlineHelpInfo.js
+       rm -rf pmgmanagerlib.js pmgmanagerlib-mobile.js OnlineHelpInfo.js
 
 
 
diff --git a/js/mobile/app.js b/js/mobile/app.js
new file mode 100644 (file)
index 0000000..68fb9e5
--- /dev/null
@@ -0,0 +1,80 @@
+var $$ = Dom7;
+var app = new Framework7({
+    root: '#app',
+    init: false,
+    name: 'Proxmox Mail Gateway',
+    routes: [
+       {
+           path: '/:path/:subpath?',
+           async: function(routeTo, routeFrom, resolve, reject) {
+               if (routeTo.params.path === 'mail') {
+                   let mail = new MailView();
+                   resolve({
+                       template: mail.getTpl()
+                   },{
+                       context: {
+                           mailid: routeTo.params.subpath
+                       }
+                   });
+               } else {
+                   reject();
+               }
+           }
+       },
+       {
+           path: '/mail/:mailid/:action',
+           async: function(routeTo, routeFrom, resolve, reject) {
+               let action = routeTo.params.action;
+               let mailid = routeTo.params.mailid;
+               let confirmText = gettext('')
+               app.dialog.confirm(
+                   `${action}: ${mailid}`,
+                   gettext('Confirm'),
+                   () => {
+                       let loader = app.dialog.preloader();
+                       app.request({
+                           method: 'POST',
+                           url: '/api2/json/quarantine/content/',
+                           data: {
+                               action: action,
+                               id: mailid
+                           },
+                           headers: {
+                               CSRFPreventionToken: Proxmox.CSRFPreventionToken
+                           },
+                           success: (data, status, xhr) => {
+                               loader.close();
+                               app.dialog.alert(
+                                   `Action '${action}' successful`,
+                                   gettext("Info"),
+                                   () => {
+                                       if (action === 'delete' ||
+                                           action === 'deliver')
+                                       {
+                                           // refresh the main list when a mail
+                                           // got deleted or delivered
+                                           app.ptr.refresh();
+                                       }
+                                   }
+                               );
+                               reject();
+                           },
+                           error: xhr => {
+                               loader.close();
+                               PMG.Utils.showError(xhr);
+                               reject();
+                           }
+                       })
+                   },
+                   () => {
+                       reject();
+                   }
+               );
+           }
+       }
+    ]
+});
+
+let quarlist = new QuarantineView();
+
+app.init();
diff --git a/js/mobile/component.js b/js/mobile/component.js
new file mode 100644 (file)
index 0000000..72312a7
--- /dev/null
@@ -0,0 +1,26 @@
+class Component {
+    constructor(config = {}) {
+       var me = this;
+       me.config = config;
+       me.data = config.data || {};
+       me.tpl = me.config.tpl || '<div class="component"></div>';
+    }
+    getTpl() {
+       var me = this;
+       if (!me._compiledtpl) {
+           me._compiledtpl = Template7.compile(me.tpl);
+       }
+       return me._compiledtpl;
+    }
+    getEl(data) {
+       var me = this;
+       if (data === undefined && me._el) {
+           return me._el;
+       } else if (data !== undefined) {
+           me.data =data;
+       }
+       me._el = Dom7(me.getTpl()(me.data));
+       return me._el;
+    }
+}
+
diff --git a/js/mobile/loginscreen.js b/js/mobile/loginscreen.js
new file mode 100644 (file)
index 0000000..36dc994
--- /dev/null
@@ -0,0 +1,114 @@
+class LoginScreen extends Component {
+    constructor(config = {}) {
+       config.tpl = `
+       <div class="login-screen">
+         <div class="view">
+           <div class="page">
+             <div class="page-content login-screen-content">
+               <div class="login-screen-title">
+               <img class="logo" src="pve2/images/logo-128.png" />
+               Proxmox Mail Gateway
+               </div>
+               <form action="/api2/json/access/ticket" method="POST" class="form-ajax-submit">
+                 <div class="list">
+                   <ul>
+                     <li class="item-content item-input">
+                       <div class="item-inner">
+                         <div class="item-title item-label">Username</div>
+                         <div class="item-input-wrap">
+                           <input type="text" name="username" placeholder="{{gettext 'Username'}}" required validate>
+                           <span class="input-clear-button"></span>
+                         </div>
+                       </div>
+                     </li>
+                     <li class="item-content item-input">
+                       <div class="item-inner">
+                         <div class="item-title item-label">Password</div>
+                         <div class="item-input-wrap">
+                           <input type="password" name="password" placeholder="{{gettext 'Password'}}" required validate>
+                           <span class="input-clear-button"></span>
+                         </div>
+                       </div>
+                     </li>
+                   </ul>
+                 </div>
+                 <div class="list">
+                   <ul>
+                     <li>
+                       <input type="submit" class="button" value='{{gettext "Log In"}}'>
+                     </li>
+                   </ul>
+                 </div>
+               </form>
+             </div>
+           </div>
+         </div>
+       </div>
+       `;
+       super(config);
+       var me = this;
+       me._screen = app.loginScreen.create({
+           content: me.getEl(),
+       });
+
+       let login = config.loginInfo;
+       me._form = me.getEl().find('form');
+
+       if (login.username && login.ticket) {
+           app.form.fillFromData(me._form, {
+               username: login.username,
+               password: login.ticket,
+           });
+           me._autoLogin = true;
+       } else if (PMG.Utils.authOK()) {
+           app.form.fillFromData(me._form, {
+               username: Proxmox.UserName,
+               password: decodeURIComponent(PMG.Utils.getCookie('PMGAuthCookie')),
+           });
+           me._autoLogin = true;
+       }
+    }
+    open(onLogin) {
+       var me = this;
+       return new Promise(function(resolve, reject) {
+           me._form.on('formajax:beforesend', (el, data, xhr) => {
+               me.loader = app.dialog.preloader();
+           });
+
+           me._form.on('formajax:success', (el, data, xhr) => {
+               let json;
+               try {
+                   json = JSON.parse(xhr.responseText);
+               } catch (err) {
+                   xhr.error = err;
+                   PMG.Utils.showError(xhr);
+                   return;
+               }
+
+               resolve(json);
+           });
+
+           me._form.on('formajax:error', (el, data, xhr) => {
+               me.loader.close();
+               PMG.Utils.showError(xhr);
+           });
+
+           if (me._autoLogin) {
+               delete me._autoLogin;
+               me._screen.on('open', () => {
+                   me._form.trigger('submit');
+               })
+           }
+
+           me._screen.open();
+       });
+    }
+    close() {
+       var me = this;
+       if (me.loader) {
+           me.loader.close();
+       }
+       me._screen.close(false);
+    }
+}
+
diff --git a/js/mobile/mailview.js b/js/mobile/mailview.js
new file mode 100644 (file)
index 0000000..c89d134
--- /dev/null
@@ -0,0 +1,62 @@
+class MailView extends Component {
+    constructor(config = {}) {
+       config.tpl = `
+       <div class="page">
+           <div class="navbar sliding">
+               <div class="navbar-inner">
+                   <div class="left">
+                 <a href="#" class="link back">
+               <i class="icon icon-back"></i>
+               <span class="ios-only">{{gettext "Back"}}</span>
+                 </a>
+                   </div>
+                   <div class="title">Preview</div>
+               </div>
+           </div>
+           <div class="fab fab-right-bottom">
+               <a href="#">
+                   <i class="icon f7-icons ios-only">menu</i>
+                   <i class="icon f7-icons ios-only">close</i>
+                   <i class="icon material-icons md-only">menu</i>
+                   <i class="icon material-icons md-only">close</i>
+               </a>
+               <div class="fab-buttons fab-buttons-top">
+                   <a href="/mail/{{mailid}}/blacklist" class="fab-label-button fab-close">
+                       <span>
+                         <i class="icon f7-icons ios-only">close</i>
+                         <i class="icon material-icons md-only">close</i>
+                       </span>
+                       <span class="fab-label">{{gettext "Blacklist"}}</span>
+                   </a>
+                   <a href="/mail/{{mailid}}/whitelist" class="fab-label-button fab-close">
+                       <span>
+                           <i class="icon f7-icons ios-only">check</i>
+                           <i class="icon material-icons md-only">check</i>
+                       </span>
+                       <span class="fab-label">{{gettext "Whitelist"}}</span>
+                   </a>
+                   <a href="/mail/{{mailid}}/delete" class="fab-label-button fab-close">
+                       <span>
+                           <i class="icon f7-icons ios-only">trash</i>
+                           <i class="icon material-icons md-only">delete</i>
+                       </span>
+                   <span class="fab-label">{{gettext "Delete"}}</span>
+                   </a>
+                   <a href="/mail/{{mailid}}/deliver" class="fab-label-button fab-close">
+                       <span>
+                           <i class="icon f7-icons ios-only">paper_plane</i>
+                           <i class="icon material-icons md-only">send</i>
+                       </span>
+                       <span class="fab-label">{{gettext "Deliver"}}</span>
+                   </a>
+               </div>
+           </div>
+           <div class="page-content">
+               <iframe frameborder=0 width="100%" height="100%" sandbox="allow-same-origin" src="/api2/htmlmail/quarantine/content?id={{mailid}}"></iframe>
+           </div>
+       </div>
+       `;
+       super(config);
+    }
+}
+
diff --git a/js/mobile/quarantineview.js b/js/mobile/quarantineview.js
new file mode 100644 (file)
index 0000000..c2e7e94
--- /dev/null
@@ -0,0 +1,329 @@
+class QuarantineView extends Component {
+    constructor(config = {}) {
+       config.tpl = config.tpl || `
+           <div class="view view-quarantine">
+           <div data-name="quarantine-list" class="page">
+               <div class="navbar">
+                   <div class="navbar-inner">
+                       <div class="left">
+                           <img class="logo-navbar" style="padding: 0 10px" src="pve2/images/logo-128.png" height=32 />
+                       </div>
+                       <div class="title">Mail Gateway</div>
+                   </div>
+               </div>
+               <div class="settings-form elevation-5 fab-morph-target">
+                   <div class="block-title block-title-medium">{{gettext "Range"}}</div>
+                   <div class="list no-hairlines-md">
+                       <ul>
+                           <li class="item-content item-input">
+                               <div class="item-inner">
+                                   <div class="item-title item-label">{{gettext "From"}}</div>
+                                   <div class="item-input-wrap">
+                                       <input type="date" name="from" placeholder="from" required validate>
+                                   </div>
+                               </div>
+                           </li>
+                           <li class="item-content item-input">
+                               <div class="item-inner">
+                                   <div class="item-title item-label">{{gettext "To"}}</div>
+                                   <div class="item-input-wrap">
+                                       <input type="date" name="to" placeholder="to" required validate>
+                                   </div>
+                               </div>
+                           </li>
+                       </ul>
+                       <a class="button fab-close range-form">{{gettext "OK"}}</a>
+                   </div>
+               </div>
+               <div class="fab fab-morph fab-right-bottom" data-morph-to=".settings-form">
+                   <a href="#">
+                       <i class="icon f7-icons ios-only">calendar</i>
+                       <i class="icon material-icons md-only">date_range</i>
+                   </a>
+               </div>
+                   <div class="toolbar subscription toolbar-hidden toolbar-bottom">
+                       <div class="toolbar-inner">
+                       <a class="button subscription">
+                           <i class="icon f7-icons ios-only color-yellow">alert</i>
+                           <i class="icon material-icons md-only color-yellow">warning</i>
+                           <span class="subscription-text">
+                           {{gettext "No valid subscription"}}
+                           </span>
+                       </a>
+                       </div>
+                   </div>
+               <div class="page-content ptr-content">
+                   <div class="ptr-preloader">
+                       <div class="preloader"></div>
+                       <div class="ptr-arrow"></div>
+                   </div>
+                   <div class="list virtual-list"></div>
+               </div>
+           </div>
+           </div>`;
+       config.itemTemplate = config.itemTemplate || `
+           <li class="swipeout">
+               <div class="swipeout-content">
+               <a href="/mail/{{id}}/" class="item-link item-content">
+                   <div class="item-inner">
+                       <div class="item-title">
+                       <div class="item-header">{{escape from}}</div>
+                       {{escape subject}}
+                       </div>
+                       <div class="item-after">Score: {{js "this.spamlevel || 0"}}</div>
+                   </div>
+               </a>
+               </div>
+               <div class="swipeout-actions-left">
+                   <a href="/mail/{{id}}/deliver" class="color-green swipeout-close">
+                       <i class="icon f7-icons ios-only">paper_plane</i>
+                       <i class="icon material-icons md-only">send</i>
+                       &nbsp;{{gettext "Deliver"}}
+                   </a>
+                   <a href="/mail/{{id}}/whitelist" class="swipeout-close">
+                       <i class="icon f7-icons ios-only">check</i>
+                       <i class="icon material-icons md-only">check</i>
+                       &nbsp;{{gettext "Whitelist"}}
+                   </a>
+               </div>
+               <div class="swipeout-actions-right">
+                   <a href="/mail/{{id}}/blacklist" class="color-orange swipeout-close">
+                       <i class="icon f7-icons ios-only">close</i>
+                       <i class="icon material-icons md-only">close</i>
+                       &nbsp;{{gettext "Blacklist"}}
+                   </a>
+                   <a href="/mail/{{id}}/delete" class="color-red swipeout-close">
+                       <i class="icon f7-icons ios-only">trash</i>
+                       <i class="icon material-icons md-only">delete</i>
+                       &nbsp;{{gettext "Delete"}}
+                   </a>
+               </div>
+           </li>`;
+       config.dividerTemplate = config.dividerTemplate ||
+           '<li class="item-divider">{{group}}</li>';
+       super(config);
+
+       var me = this;
+
+       me._compiledItemTemplate = Template7.compile(me.config.itemTemplate);
+       me._compiledDividerTemplate = Template7.compile(me.config.dividerTemplate);
+       me.skelTpl = `
+           <li class="skeleton-text skeleton-effect-fade">
+               <a href="#" class="item-content item-link">
+               <div class="item-inner">
+                   <div class="item-title">
+                       <div class="item-header">_______________________</div>
+                       ____ ______ __ _______ ____ _______ _______ ___
+                   </div>
+                   <div class="item-after">Score: 15</div>
+               </div>
+               </a>
+           </li>`;
+       me.skelDividerTpl = '<li class="item-divider skeleton-text">____-__-__</li>';
+       me.setEndtime(new Date());
+       let startdate = new Date();
+       startdate.setDate(startdate.getDate() - 7);
+       me.setStarttime(startdate);
+
+       // add to dom
+       $$(me.config.target || '#app').append(me.getEl());
+
+       $$(document).on('page:init', '.page[data-name=quarantine-list]', (e, page) => {
+           me.vList = app.virtualList.create({
+               el: '.virtual-list',
+               items: [],
+               renderItem: function(item) {
+                   return me._renderItem(item);
+               },
+               emptyTemplate: '<div class="empty">No data in database</div>'
+           });
+
+           // setup pull to refresh
+           $$('.ptr-content').on('ptr:refresh', (e) => {
+               me.setItems([
+                   { skel: true, divider: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true, divider: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true },
+                   { skel: true },
+               ]);
+               me.load().then(data => {
+                   me.setItems(data, {
+                       sorter: {
+                           property: 'time',
+                           numeric: true,
+                           direction: 'DESC'
+                       },
+                       grouperFn: (val) => PMG.Utils.unixToIso(val['time'])
+                   });
+               }).catch(PMG.Utils.showError).then(() => {
+                   e.detail();
+               });
+           });
+
+           // process query parameters
+           let { mail, action, date, username, ticket } = PMG.Utils.extractParams();
+           if (date) {
+               me.setStarttime(date);
+           }
+
+           // setup range form
+           $$('input[name=from]').val(PMG.Utils.unixToIso(me.starttime));
+           $$('input[name=to]').val(PMG.Utils.unixToIso(me.endtime));
+
+           $$('.fab').on('fab:close', () => {
+               let fromChanged = me.setStarttime($$('input[name=from]').val());
+               let toChanged = me.setEndtime($$('input[name=to]').val());
+               if (fromChanged || toChanged) {
+                   app.ptr.refresh();
+               }
+           });
+
+           // check login
+
+           let loginInfo = { username, ticket };
+           let showPopup = (username && ticket) || !PMG.Utils.authOK();
+           me._loginScreen = new LoginScreen({ loginInfo });
+
+           me._loginScreen.open().then(data => {
+               me._loginScreen.close();
+               PMG.Utils.setLoginInfo(data);
+               return PMG.Utils.getSubscriptionInfo();
+           }).then(data => {
+               return PMG.Utils.checkSubscription(data, showPopup);
+           }).then(data => {
+               app.ptr.refresh();
+               if (mail) {
+                   let url = "/mail/" + mail + "/" + (action || "");
+                   me._view.router.navigate(url);
+               }
+           }).catch(PMG.Utils.showError);
+       });
+
+       me._view = app.views.create('.view-quarantine', {
+           main: me.config.mainView !== undefined ? me.config.mainView : true,
+           url: '/',
+           pushState: true,
+           pushStateAnimateOnLoad: true
+       });
+    }
+    setStarttime(starttime) {
+       var me = this;
+       let date = starttime;
+       if (!(starttime instanceof Date)) {
+           // we assume an ISO string
+           if (starttime == '') {
+               return;
+           }
+           date = new Date(PMG.Utils.isoToUnix(starttime)*1000);
+       }
+       // starttime is at beginning of date
+       date.setHours(0,0,0,0);
+       let result = Math.round(date.getTime()/1000);
+       if (result !== me.starttime) {
+           me.starttime = result;
+           return true;
+       }
+       return false
+    }
+    setEndtime(endtime) {
+       var me = this;
+       let date = endtime;
+       if (!(endtime instanceof Date)) {
+           if (endtime == '') {
+               return;
+           }
+           // we assume an ISO string
+           date = new Date(PMG.Utils.isoToUnix(endtime)*1000);
+       }
+       // endtime is at the end of the day
+       date.setHours(23, 59, 59);
+       let result = Math.round(date.getTime()/1000);
+       if (result !== me.endtime) {
+           me.endtime = result;
+           return true;
+       }
+       return false;
+    }
+    _renderItem(item) {
+       var me = this;
+
+       if(typeof item === 'object') {
+           if (item.skel) {
+               return item.divider? me.skelDividerTpl : me.skelTpl;
+           } else if (item.divider) {
+               return me._compiledDividerTemplate(item);
+           } else {
+               return me._compiledItemTemplate(item);
+           }
+       }
+
+       return item.toString();
+    }
+    setItems(items, options) {
+       var me = this;
+       if (options && options.sorter) {
+           if (options.sorter.sorterFn) {
+               items.sort(options.sorter.sorterFn);
+           } else {
+               let prop = options.sorter.property;
+               let numeric = options.sorter.numeric;
+               let dir = options.sorter.direction === "ASC" ? 1 : -1;
+               items.sort((a,b) => {
+                   let result;
+
+                   if (numeric) {
+                       result = a[prop] - b[prop];
+                   } else {
+                       result = a[prop] === b[prop] ? 0 : (a[prop] < b[prop] ? 1 : -1);
+                   }
+
+                   return result * dir;
+               });
+           }
+       }
+       me.vList.replaceAllItems(items);
+       if (options && options.grouperFn) {
+           let lastgroup;
+           let offset = 0;
+           for (let i = 0; i+offset < items.length; i++) {
+               let item = items[i+offset];
+               let curgroup = options.grouperFn(item);
+               if (curgroup != lastgroup) {
+                   me.vList.insertItemBefore(i+(offset++), {
+                       divider: true,
+                       group: curgroup
+                   });
+                   lastgroup = curgroup;
+               }
+           }
+       }
+    }
+    load() {
+       var me = this;
+       return new Promise(function(resolve, reject) {
+           app.request({
+               url: '/api2/json/quarantine/spam',
+               data: {
+                   starttime: me.starttime,
+                   endtime: me.endtime
+               },
+               dataType: 'json',
+               success: (response, status, xhr) => {
+                   resolve(response.data);
+               },
+               error: xhr => {
+                   reject(xhr);
+               }
+           });
+       });
+    }
+}
+
diff --git a/js/mobile/utils.js b/js/mobile/utils.js
new file mode 100644 (file)
index 0000000..b0e082b
--- /dev/null
@@ -0,0 +1,163 @@
+Template7.registerHelper('gettext', function(value) {
+    return gettext(value);
+});
+
+var PMG = {
+    Utils: {
+       getCookie(name) {
+           let cookies = document.cookie.split(/;\s*/);
+           for (let i = 0; i < cookies.length; i++) {
+               let cookie = cookies[i].split('=');
+               if (cookie[0] === name && cookie.length > 1) {
+                   return cookie[1];
+               }
+           }
+           return undefined;
+       },
+       setCookie(name, value, expires) {
+           value = encodeURIComponent(value);
+           let cookie = `${name}=${value}`;
+           if (expires) {
+               cookie += `; expires=${expires}`;
+           }
+           document.cookie = cookie;
+       },
+       deleteCookie(name) {
+           PMG.Utils.setCookie(name, "", "Thu, 01 Jan 1970 00:00:00 UTC");
+       },
+       authOK(options) {
+           var authCookie = PMG.Utils.getCookie('PMGAuthCookie') || "";
+           return (authCookie.substr(0,7) === 'PMGQUAR' && Proxmox.UserName !== '');
+       },
+       isoToUnix(iso) {
+           let fields = iso.split('-').map((field) => parseInt(field, 10));
+           // monthIndex starts at 0
+           let date = new Date(fields[0],fields[1]-1, fields[2]);
+           return Math.round(date.getTime()/1000);
+       },
+       unixToIso(unix) {
+           let date = new Date(unix*1000);
+           let year = date.getFullYear().toString();
+           let month = (date.getMonth()+1).toString().padStart(2, "0");
+           let day = date.getDate().toString().padStart(2, "0");
+           return `${year}-${month}-${day}`;
+       },
+       showError(xhr) {
+           let statusText = "", errorText = "";
+           if (xhr instanceof Error) {
+               statusText = gettext("Error");
+               errorText = xhr.message;
+           } else if (xhr.error instanceof Error) {
+               statusText = gettext("Error");
+               errorText = xhr.error.message;
+           } else {
+               statusText = xhr.status.toString() + ' ' + xhr.statusText;
+               try {
+                   let errorObj = JSON.parse(xhr.responseText);
+                   if (errorObj.errors) {
+                       let errors = Object.keys(errorObj.errors).map((key) => key + ": " + errorObj.errors[key]);
+                       errorText = errors.join('<br>');
+                   }
+               } catch (e) {
+                   statusText = gettext("Error");
+                   errorText = e.message;
+               }
+           }
+           app.toast.show({
+               text: `Error:<br>
+                   ${statusText}<br>
+                   ${errorText}
+                   `,
+               closeButton: true,
+               destroyOnClose: true
+           });
+       },
+       extractParams() {
+           let queryObj = app.utils.parseUrlQuery(location.search);
+           let mail, action, date, username, ticket;
+           if (queryObj.ticket) {
+               let tocheck = decodeURIComponent(queryObj.ticket);
+               let match = tocheck.match(/^PMGQUAR:([^\s\:]+):/);
+               if (match) {
+                   ticket = tocheck;
+                   username = match[1];
+               }
+               delete queryObj.ticket;
+           }
+
+           if (queryObj.date) {
+               date =queryObj.date;
+               delete queryObj.date;
+           }
+
+           if (queryObj.cselect) {
+               mail = queryObj.cselect;
+               action = queryObj.action;
+               delete queryObj.cselect;
+               delete queryObj.action;
+           }
+
+           if (mail || action || date || ticket) {
+               let queryString = app.utils.serializeObject(queryObj);
+               window.history.replaceState(
+                       window.history.state,
+                       document.title,
+                       location.pathname + (queryString? "?" + queryString : '')
+               );
+           }
+
+           return { mail, action, date, username, ticket };
+       },
+       setLoginInfo(result) {
+           PMG.Utils.setCookie('PMGAuthCookie', result.data.ticket);
+           Proxmox.CSRFPreventionToken = result.data.CSRFPreventionToken;
+       },
+       getSubscriptionInfo() {
+           return new Promise(function(resolve, reject) {
+               app.request({
+                   url: '/api2/json/nodes/localhost/subscription',
+                   dataType: 'json',
+                   success: (result, status, xhr) => {
+                       resolve(result.data);
+                   },
+                   error: (xhr, status) => {
+                       reject(xhr);
+                   }
+               });
+           });
+       },
+       checkSubscription(data, showPopup) {
+           return new Promise(function(resolve, reject) {
+               if (data.status !== 'Active') {
+                   let url = data.url || 'https://wwww.proxmox.com';
+                   let err = `You do not have a valid subscription for this server.
+                           Please visit
+                           <a target="_blank" href="${url}">www.proxmox.com</a>
+                           to get a list of available options.`;
+                   app.toolbar.show('.toolbar.subscription');
+                   $$('.button.subscription').on('click', () => {
+                       app.dialog.alert(
+                           err,
+                           gettext("No valid subscription"),
+                       );
+                   });
+                   if (showPopup) {
+                       app.dialog.alert(
+                           err,
+                           gettext("No valid subscription"),
+                           () => {
+                               resolve(data);
+                           }
+                       );
+                   } else {
+                       resolve();
+                   }
+               } else {
+                   app.toolbar.hide('.toolbar.subscription');
+                   resolve();
+               }
+           });
+       }
+    }
+};
+
diff --git a/pmg-mobile-index.html.tt b/pmg-mobile-index.html.tt
new file mode 100644 (file)
index 0000000..cb08971
--- /dev/null
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, minimal-ui, viewport-fit=cover">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="theme-color" content="#2196f3">
+    <title>Proxmox Mail Gateway - Quarantine</title>
+    <link rel="stylesheet" href="framework7/css/framework7.bundle.min.css">
+    <link rel="stylesheet" href="framework7/css/framework7-icons.css">
+    <link rel="stylesheet" href="framework7/css/material-icons.css">
+    <link rel="stylesheet" href="pve2/css/ext6-pmg-mobile.css">
+    [% IF langfile %]
+    <script type='text/javascript' src='/pve2/locale/pmg-lang-[% lang %].js'></script>
+    [% ELSE %]
+    <script type='text/javascript'> function gettext(buf) { return buf; } </script>
+    [%- END %]
+    <script type="text/javascript">
+      Proxmox = {
+        UserName: '[% username %]',
+        CSRFPreventionToken: '[% token %]'
+      };
+    </script>
+  </head>
+  <body>
+    <div id="app">
+      <div class="statusbar"></div>
+    </div>
+    [% IF debug %]
+    <script type="text/javascript" src="/framework7/js/framework7.bundle.js"></script>
+    [% ELSE %]
+    <script type="text/javascript" src="/framework7/js/framework7.bundle.min.js"></script>
+    [% END %]
+    <script type="text/javascript" src="/pve2/js/pmgmanagerlib-mobile.js"></script>
+  </body>
+</html>