浏览代码

Add Domain and Mailbox tagging (#4569)

* [Web] define tag tables

* [Web] add mailbox tag functions

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* Include new tags lang in language.en.json

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

* [Web] add domain/mailbox tagging

Co-authored-by: Niklas Meyer <62480600+DerLinkman@users.noreply.github.com>
FreddleSpl0it 3 年之前
父节点
当前提交
549ff7d100

+ 1 - 1
data/conf/nginx/includes/site-defaults.conf

@@ -65,7 +65,7 @@
   }
 
   location ~ ^/api/v1/(.*)$ {
-    try_files $uri $uri/ /json_api.php?query=$1;
+    try_files $uri $uri/ /json_api.php?query=$1&$args;
   }
 
   location ^~ /.well-known/acme-challenge/ {

+ 154 - 0
data/web/api/openapi.yaml

@@ -497,6 +497,7 @@ paths:
                           relay_all_recipients: "0"
                           rl_frame: s
                           rl_value: "10"
+                          tags: ["tag1", "tag2"]
                         - null
                       msg:
                         - domain_added
@@ -544,6 +545,7 @@ paths:
                 rl_frame: s
                 rl_value: "10"
                 restart_sogo: "10"
+                tags: ["tag1", "tag2"]
               properties:
                 active:
                   description: is domain active or not
@@ -1010,6 +1012,7 @@ paths:
                           force_pw_update: "1"
                           tls_enforce_in: "1"
                           tls_enforce_out: "1"
+                          tags: ["tag1", "tag2"]
                         - null
                       msg:
                         - mailbox_added
@@ -1054,6 +1057,7 @@ paths:
                 force_pw_update: "1"
                 tls_enforce_in: "1"
                 tls_enforce_out: "1"
+                tags: ["tag1", "tag2"]
               properties:
                 active:
                   description: is mailbox active or not
@@ -2716,6 +2720,140 @@ paths:
                   type: object
               type: object
       summary: Delete Transport Maps
+  "/api/v1/delete/mailbox/tag/{mailbox}":
+    post:
+      parameters:
+        - description: name of mailbox
+          in: path
+          name: mailbox
+          example: info@domain.tld
+          required: true
+          schema:
+            type: string
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                    - log:
+                        - mailbox
+                        - delete
+                        - tags_mailbox
+                        - tags:
+                          - tag1
+                          - tag2
+                          mailbox: info@domain.tld
+                        - null
+                      msg:
+                        - mailbox_modified
+                        - info@domain.tld
+                      type: success
+              schema:
+                properties:
+                  log:
+                    description: contains request object
+                    items: {}
+                    type: array
+                  msg:
+                    items: {}
+                    type: array
+                  type:
+                    enum:
+                      - success
+                      - danger
+                      - error
+                    type: string
+                type: object
+          description: OK
+          headers: {}
+      tags:
+        - Mailboxes
+      description: You can delete one or more mailbox tags.
+      operationId: Delete mailbox tags
+      requestBody:
+        content:
+          application/json:
+            schema:
+              example:
+                - tag1
+                - tag2
+              properties:
+                items:
+                  description: contains list of mailboxes you want to delete
+                  type: object
+              type: object
+      summary: Delete mailbox tags
+  "/api/v1/delete/domain/tag/{domain}":
+    post:
+      parameters:
+        - description: name of domain
+          in: path
+          name: domain
+          example: domain.tld
+          required: true
+          schema:
+            type: string
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                    - log:
+                        - mailbox
+                        - delete
+                        - tags_domain
+                        - tags:
+                          - tag1
+                          - tag2
+                          domain: domain.tld
+                        - null
+                      msg:
+                        - domain_modified
+                        - domain.tld
+                      type: success
+              schema:
+                properties:
+                  log:
+                    description: contains request object
+                    items: {}
+                    type: array
+                  msg:
+                    items: {}
+                    type: array
+                  type:
+                    enum:
+                      - success
+                      - danger
+                      - error
+                    type: string
+                type: object
+          description: OK
+          headers: {}
+      tags:
+        - Domains
+      description: You can delete one or more domain tags.
+      operationId: Delete domain tags
+      requestBody:
+        content:
+          application/json:
+            schema:
+              example:
+                - tag1
+                - tag2
+              properties:
+                items:
+                  description: contains list of domains you want to delete
+                  type: object
+              type: object
+      summary: Delete domain tags
   /api/v1/edit/alias:
     post:
       responses:
@@ -2865,6 +3003,7 @@ paths:
                   quota: "10240"
                   relay_all_recipients: "0"
                   relayhost: "2"
+                  tags: ["tag3", "tag4"]
                 items: domain.tld
               properties:
                 attr:
@@ -3019,6 +3158,7 @@ paths:
                           sogo_access: "1"
                           username:
                             - info@domain.tld
+                          tags: ["tag3", "tag4"]
                         - null
                       msg:
                         - mailbox_modified
@@ -3066,6 +3206,7 @@ paths:
                     - domain3.tld
                     - "*"
                   sogo_access: "1"
+                  tags: ["tag3", "tag4"]
                 items:
                   - info@domain.tld
               properties:
@@ -3793,6 +3934,11 @@ paths:
               - all
               - mailcow.tld
             type: string
+        - description: comma seperated list of tags to filter by
+          example: "tag1,tag2"
+          in: query
+          name: tags
+          required: false
         - description: e.g. api-key-string
           example: api-key-string
           in: header
@@ -3831,6 +3977,7 @@ paths:
                       relay_all_recipients: "0"
                       relayhost: "0"
                       rl: false
+                      tags: ["tag1", "tag2"]
                     - active: "1"
                       aliases_in_domain: 0
                       aliases_left: 400
@@ -3853,6 +4000,7 @@ paths:
                       relay_all_recipients: "0"
                       relayhost: "0"
                       rl: false
+                      tags: ["tag3", "tag4"]
           description: OK
           headers: {}
       tags:
@@ -4345,6 +4493,11 @@ paths:
               - all
               - user@domain.tld
             type: string
+        - description: comma seperated list of tags to filter by
+          example: "tag1,tag2"
+          in: query
+          name: tags
+          required: false
         - description: e.g. api-key-string
           example: api-key-string
           in: header
@@ -4382,6 +4535,7 @@ paths:
                       rl: false
                       spam_aliases: 0
                       username: info@doman3.tld
+                      tags: ["tag1", "tag2"]
           description: OK
           headers: {}
       tags:

+ 37 - 0
data/web/css/build/008-mailcow.css

@@ -256,3 +256,40 @@ code {
 .flag-icon {
   margin-right: 5px;
 }
+
+.tag-box {
+  display: flex;
+  flex-wrap: wrap;
+  height: auto;
+}
+.tag-badge {
+  transition: 200ms linear;
+  margin-top: 5px;
+  margin-bottom: 5px;
+  margin-left: 2px;
+  margin-right: 2px;
+}
+.tag-badge.btn-badge {
+  cursor: pointer;
+}
+.tag-badge .bi {
+  font-size: 12px;
+}
+.tag-badge.btn-badge:hover {
+  filter: brightness(0.9);
+}
+.tag-input {
+  margin-left: 10px;
+  border: 0;
+  flex: 1;
+  height: 24px;
+  min-width: 150px;
+}
+.tag-input:focus {
+  outline: none;
+}
+.tag-add {
+  padding: 0 5px 0 5px;
+  align-items: center;
+  display: inline-flex;
+}

+ 2 - 0
data/web/edit.php

@@ -54,6 +54,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
           'rl' => $rl,
           'rlyhosts' => $rlyhosts,
           'dkim' => dkim('details', $domain),
+          'domain_details' => $result,
         ];
     }
     elseif (isset($_GET['oauth2client']) &&
@@ -99,6 +100,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
         'rlyhosts' => $rlyhosts,
         'sender_acl_handles' => mailbox('get', 'sender_acl_handles', $mailbox),
         'user_acls' => acl('get', 'user', $mailbox),
+        'mailbox_details' => $result
       ];
     }
     elseif (isset($_GET['relayhost']) && is_numeric($_GET["relayhost"]) && !empty($_GET["relayhost"])) {

+ 281 - 19
data/web/inc/functions.mailbox.inc.php

@@ -443,16 +443,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           if ($_SESSION['mailcow_cc_role'] != "admin") {
             $_SESSION['return'][] = array(
               'type' => 'danger',
-              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_extra),
               'msg' => 'access_denied'
             );
             return false;
           }
           $domain       = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46);
           $description  = $_data['description'];
-          if (empty($description)) {
-            $description = $domain;
-          }
+          if (empty($description)) $description = $domain;
+          $tags         = (array)$_data['tags'];
           $aliases      = (int)$_data['aliases'];
           $mailboxes    = (int)$_data['mailboxes'];
           $defquota     = (int)$_data['defquota'];
@@ -545,10 +544,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             );
             return false;
           }
+
           $stmt = $pdo->prepare("DELETE FROM `sender_acl` WHERE `external` = 1 AND `send_as` LIKE :domain");
           $stmt->execute(array(
             ':domain' => '%@' . $domain
           ));
+          // save domain
           $stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`)
             VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients)");
           $stmt->execute(array(
@@ -565,6 +566,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             ':relay_unknown_only' => $relay_unknown_only,
             ':relay_all_recipients' => $relay_all_recipients
           ));
+          // save tags
+          foreach($tags as $index => $tag){
+            if ($index > $GLOBALS['TAGGING_LIMIT']) {
+              $_SESSION['return'][] = array(
+                'type' => 'warning',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => array('tag_limit_exceeded', 'limit '.$GLOBALS['TAGGING_LIMIT'])
+              );
+              break;
+            }
+            $stmt = $pdo->prepare("INSERT INTO `tags_domain` (`domain`, `tag_name`) VALUES (:domain, :tag_name)");
+            $stmt->execute(array(
+              ':domain' => $domain,
+              ':tag_name' => $tag,
+            ));
+          }
+
           try {
             $redis->hSet('DOMAIN_MAP', $domain, 1);
           }
@@ -942,6 +960,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $password     = $_data['password'];
           $password2    = $_data['password2'];
           $name         = ltrim(rtrim($_data['name'], '>'), '<');
+          $tags         = $_data['tags'];
           $quota_m      = intval($_data['quota']);
           if ((!isset($_SESSION['acl']['unlimited_quota']) || $_SESSION['acl']['unlimited_quota'] != "1") && $quota_m === 0) {
             $_SESSION['return'][] = array(
@@ -1103,6 +1122,22 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $stmt->execute(array(
             ':username' => $username
           ));
+          // save tags
+          foreach($tags as $index => $tag){
+            if ($index > $GLOBALS['TAGGING_LIMIT']) {
+              $_SESSION['return'][] = array(
+                'type' => 'warning',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => array('tag_limit_exceeded', 'limit '.$GLOBALS['TAGGING_LIMIT'])
+              );
+              break;
+            }
+            $stmt = $pdo->prepare("INSERT INTO `tags_mailbox` (`username`, `tag_name`) VALUES (:username, :tag_name)");
+            $stmt->execute(array(
+              ':username' => $username,
+              ':tag_name' => $tag,
+            ));
+          }
           $stmt = $pdo->prepare("INSERT INTO `quota2` (`username`, `bytes`, `messages`)
             VALUES (:username, '0', '0') ON DUPLICATE KEY UPDATE `bytes` = '0', `messages` = '0';");
           $stmt->execute(array(':username' => $username));
@@ -2146,6 +2181,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 $gal                  = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
                 $description          = (!empty($_data['description']) && isset($_SESSION['acl']['domain_desc']) && $_SESSION['acl']['domain_desc'] == "1") ? $_data['description'] : $is_now['description'];
                 (int)$relayhost       = (isset($_data['relayhost']) && isset($_SESSION['acl']['domain_relayhost']) && $_SESSION['acl']['domain_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['relayhost']);
+                $tags                 = (is_array($_data['tags']) ? $_data['tags'] : array());
               }
               else {
                 $_SESSION['return'][] = array(
@@ -2155,6 +2191,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 );
                 continue;
               }
+
               $stmt = $pdo->prepare("UPDATE `domain` SET
               `description` = :description,
               `gal` = :gal
@@ -2164,6 +2201,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 ':gal' => $gal,
                 ':domain' => $domain
               ));
+              // save tags, tag_name is unique
+              foreach($tags as $index => $tag){
+                if ($index > $GLOBALS['TAGGING_LIMIT']) {
+                  $_SESSION['return'][] = array(
+                    'type' => 'warning',
+                    'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                    'msg' => array('tag_limit_exceeded', 'limit '.$GLOBALS['TAGGING_LIMIT'])
+                  );
+                  break;
+                }
+                $stmt = $pdo->prepare("INSERT INTO `tags_domain` (`domain`, `tag_name`) VALUES (:domain, :tag_name)");
+                $stmt->execute(array(
+                  ':domain' => $domain,
+                  ':tag_name' => $tag,
+                ));
+              }
+
               $_SESSION['return'][] = array(
                 'type' => 'success',
                 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
@@ -2185,6 +2239,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 $maxquota             = (!empty($_data['maxquota'])) ? $_data['maxquota'] : ($is_now['max_quota_for_mbox'] / 1048576);
                 $quota                = (!empty($_data['quota'])) ? $_data['quota'] : ($is_now['max_quota_for_domain'] / 1048576);
                 $description          = (!empty($_data['description'])) ? $_data['description'] : $is_now['description'];
+                $tags                 = (is_array($_data['tags']) ? $_data['tags'] : array());
                 if ($relay_all_recipients == '1') {
                   $backupmx = '1';
                 }
@@ -2283,6 +2338,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 );
                 continue;
               }
+
               $stmt = $pdo->prepare("UPDATE `domain` SET
               `relay_all_recipients` = :relay_all_recipients,
               `relay_unknown_only` = :relay_unknown_only,
@@ -2312,6 +2368,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 ':description' => $description,
                 ':domain' => $domain
               ));
+              // save tags, tag_name is unique
+              foreach($tags as $index => $tag){
+                if ($index > $GLOBALS['TAGGING_LIMIT']) {
+                  $_SESSION['return'][] = array(
+                    'type' => 'warning',
+                    'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                    'msg' => array('tag_limit_exceeded', 'limit '.$GLOBALS['TAGGING_LIMIT'])
+                  );
+                  break;
+                }
+                $stmt = $pdo->prepare("INSERT INTO `tags_domain` (`domain`, `tag_name`) VALUES (:domain, :tag_name)");
+                $stmt->execute(array(
+                  ':domain' => $domain,
+                  ':tag_name' => $tag,
+                ));
+              }
+
               $_SESSION['return'][] = array(
                 'type' => 'success',
                 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
@@ -2360,6 +2433,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               $quota_b    = $quota_m * 1048576;
               $password   = (!empty($_data['password'])) ? $_data['password'] : null;
               $password2  = (!empty($_data['password2'])) ? $_data['password2'] : null;
+              $tags       = (is_array($_data['tags']) ? $_data['tags'] : array());
             }
             else {
               $_SESSION['return'][] = array(
@@ -2636,6 +2710,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               ':relayhost' => $relayhost,
               ':username' => $username
             ));
+            // save tags
+            foreach($tags as $index => $tag){
+              if ($index > $GLOBALS['TAGGING_LIMIT']) {
+                $_SESSION['return'][] = array(
+                  'type' => 'warning',
+                  'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                  'msg' => array('tag_limit_exceeded', 'limit '.$GLOBALS['TAGGING_LIMIT'])
+                );
+                break;
+              }
+              $stmt = $pdo->prepare("INSERT INTO `tags_mailbox` (`username`, `tag_name`) VALUES (:username, :tag_name)");
+              $stmt->execute(array(
+                ':username' => $username,
+                ':tag_name' => $tag,
+              ));
+            }
+            
             $_SESSION['return'][] = array(
               'type' => 'success',
               'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
@@ -2851,10 +2942,34 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
         break;
         case 'mailboxes':
           $mailboxes = array();
-          if (isset($_data) && !hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
-            return false;
+          if (isset($_extra) && is_array($_extra) && isset($_data)) {
+            // get by domain and tags
+            $tags = is_array($_extra) ? $_extra : array();
+
+            $sql = "";
+            foreach ($tags as $key => $tag) {
+              $sql = $sql."SELECT DISTINCT `username` FROM `tags_mailbox` WHERE `username` LIKE ? AND `tag_name` LIKE ?"; // distinct, avoid duplicates
+              if ($key === array_key_last($tags)) break;
+              $sql = $sql.' UNION DISTINCT '; // combine querys with union - distinct, avoid duplicates
+            }
+
+            // prepend domain to array
+            $params = array();
+            foreach ($tags as $key => $val){ 
+              array_push($params, '%'.$_data.'%');
+              array_push($params, '%'.$val.'%');
+            }
+            $stmt = $pdo->prepare($sql);
+            $stmt->execute($params);
+
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], explode('@', $row['username'])[1])) 
+                $mailboxes[] = $row['username'];
+            }
           }
           elseif (isset($_data) && hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
+            // get by domain
             $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE (`kind` = '' OR `kind` = NULL) AND `domain` = :domain");
             $stmt->execute(array(
               ':domain' => $_data,
@@ -3348,20 +3463,46 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           if ($_SESSION['mailcow_cc_role'] != "admin" && $_SESSION['mailcow_cc_role'] != "domainadmin") {
             return false;
           }
-          $stmt = $pdo->prepare("SELECT `domain` FROM `domain`
-            WHERE (`domain` IN (
-              SELECT `domain` from `domain_admins`
-                WHERE (`active`='1' AND `username` = :username))
-              )
-              OR 'admin'= :role");
-          $stmt->execute(array(
-            ':username' => $_SESSION['mailcow_cc_username'],
-            ':role' => $_SESSION['mailcow_cc_role'],
-          ));
-          $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
-          while($row = array_shift($rows)) {
-            $domains[] = $row['domain'];
+
+          if (isset($_extra) && is_array($_extra)){
+            // get by tags
+            $tags = is_array($_extra) ? $_extra : array();
+            // add % as prefix and suffix to every element for relative searching
+            $tags = array_map(function($x){ return '%'.$x.'%'; }, $tags);
+            $sql = "";
+            foreach ($tags as $key => $tag) {
+              $sql = $sql."SELECT DISTINCT `domain` FROM `tags_domain` WHERE `tag_name` LIKE ?"; // distinct, avoid duplicates
+              if ($key === array_key_last($tags)) break;
+              $sql = $sql.' UNION DISTINCT '; // combine querys with union - distinct, avoid duplicates
+            }
+            $stmt = $pdo->prepare($sql);
+            $stmt->execute($tags);
+
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              if ($_SESSION['mailcow_cc_role'] == "admin")
+                $domains[] = $row['domain'];
+              elseif (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['domain'])) 
+                $domains[] = $row['domain'];
+            }
+          } else {
+            // get all
+            $stmt = $pdo->prepare("SELECT `domain` FROM `domain`
+              WHERE (`domain` IN (
+                SELECT `domain` from `domain_admins`
+                  WHERE (`active`='1' AND `username` = :username))
+                )
+                OR 'admin'= :role");
+            $stmt->execute(array(
+              ':username' => $_SESSION['mailcow_cc_username'],
+              ':role' => $_SESSION['mailcow_cc_role'],
+            ));
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              $domains[] = $row['domain'];
+            }
           }
+
           return $domains;
         break;
         case 'domain_details':
@@ -3478,6 +3619,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               $domain_admins = $stmt->fetch(PDO::FETCH_ASSOC);
               (isset($domain_admins['domain_admins'])) ? $domaindata['domain_admins'] = $domain_admins['domain_admins'] : $domaindata['domain_admins'] = "-";
           }
+          $stmt = $pdo->prepare("SELECT `tag_name`
+            FROM `tags_domain` WHERE `domain`= :domain");
+          $stmt->execute(array(
+            ':domain' => $_data
+          ));
+          $tags = $stmt->fetchAll(PDO::FETCH_ASSOC);
+          while ($tag = array_shift($tags)) {
+            $domaindata['tags'][] = $tag['tag_name'];
+          }
+
           return $domaindata;
         break;
         case 'mailbox_details':
@@ -3613,6 +3764,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             }
             $mailboxdata['is_relayed'] = $row['backupmx'];
           }
+          $stmt = $pdo->prepare("SELECT `tag_name`
+            FROM `tags_mailbox` WHERE `username`= :username");
+          $stmt->execute(array(
+            ':username' => $_data
+          ));
+          $tags = $stmt->fetchAll(PDO::FETCH_ASSOC);
+          while ($tag = array_shift($tags)) {
+            $mailboxdata['tags'][] = $tag['tag_name'];
+          }
 
           return $mailboxdata;
         break;
@@ -4342,6 +4502,108 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             );
           }
         break;
+        case 'tags_domain':    
+          if (!is_array($_data['domain'])) {
+            $domains = array();
+            $domains[] = $_data['domain'];
+          }
+          else {
+            $domains = $_data['domain'];
+          }
+          $tags = $_data['tags'];
+          if (!is_array($tags)) $tags = array();
+
+          
+          if ($_SESSION['mailcow_cc_role'] != "admin") {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'msg' => 'access_denied'
+            );
+            return false;
+          }
+
+          $wasModified = false;
+          foreach ($domains as $domain) {            
+            if (!is_valid_domain_name($domain)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'domain_invalid'
+              );
+              continue;
+            }
+
+            foreach($tags as $tag){
+              // delete tag
+              $wasModified = true;
+              $stmt = $pdo->prepare("DELETE FROM `tags_domain` WHERE `domain` = :domain AND `tag_name` = :tag_name");
+              $stmt->execute(array(
+                ':domain' => $domain,
+                ':tag_name' => $tag,
+              ));
+            }
+          }
+
+          if (!$wasModified) return false;
+          $_SESSION['return'][] = array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+            'msg' => array('domain_modified', $domain)
+          );
+        break;
+        case 'tags_mailbox':
+          if (!is_array($_data['username'])) {
+            $usernames = array();
+            $usernames[] = $_data['username'];
+          }
+          else {
+            $usernames = $_data['username'];
+          }
+          $tags = $_data['tags'];
+          if (!is_array($tags)) $tags = array();
+
+          $wasModified = false;
+          foreach ($usernames as $username) {
+            if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'email invalid'
+              );
+              continue;
+            }
+
+            $is_now = mailbox('get', 'mailbox_details', $username);
+            $domain     = $is_now['domain'];
+            if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'access_denied'
+              );
+              continue;
+            }
+
+            // delete tags
+            foreach($tags as $tag){
+              $wasModified = true;
+              
+              $stmt = $pdo->prepare("DELETE FROM `tags_mailbox` WHERE `username` = :username AND `tag_name` = :tag_name");
+              $stmt->execute(array(
+                ':username' => $username,
+                ':tag_name' => $tag,
+              ));
+            }
+          }
+
+          if (!$wasModified) return false;
+          $_SESSION['return'][] = array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+            'msg' => array('mailbox_modified', $username)
+          );
+        break;
       }
     break;
   }

+ 70 - 30
data/web/inc/init_db.inc.php

@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
     global $pdo;
 
-    $db_version = "22032022_1330";
+    $db_version = "02052022_1500";
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -23,35 +23,35 @@ function init_db_schema() {
     }
 
     $views = array(
-    "grouped_mail_aliases" => "CREATE VIEW grouped_mail_aliases (username, aliases) AS
-      SELECT goto, IFNULL(GROUP_CONCAT(address ORDER BY address SEPARATOR ' '), '') AS address FROM alias
-      WHERE address!=goto
-      AND active = '1'
-      AND sogo_visible = '1'
-      AND address NOT LIKE '@%'
-      GROUP BY goto;",
-    // START
-    // Unused at the moment - we cannot allow to show a foreign mailbox as sender address in SOGo, as SOGo does not like this
-    // We need to create delegation in SOGo AND set a sender_acl in mailcow to allow to send as user X
-    "grouped_sender_acl" => "CREATE VIEW grouped_sender_acl (username, send_as_acl) AS
-      SELECT logged_in_as, IFNULL(GROUP_CONCAT(send_as SEPARATOR ' '), '') AS send_as_acl FROM sender_acl
-      WHERE send_as NOT LIKE '@%'
-      GROUP BY logged_in_as;",
-    // END 
-    "grouped_sender_acl_external" => "CREATE VIEW grouped_sender_acl_external (username, send_as_acl) AS
-      SELECT logged_in_as, IFNULL(GROUP_CONCAT(send_as SEPARATOR ' '), '') AS send_as_acl FROM sender_acl
-      WHERE send_as NOT LIKE '@%' AND external = '1'
-      GROUP BY logged_in_as;",
-    "grouped_domain_alias_address" => "CREATE VIEW grouped_domain_alias_address (username, ad_alias) AS
-      SELECT username, IFNULL(GROUP_CONCAT(local_part, '@', alias_domain SEPARATOR ' '), '') AS ad_alias FROM mailbox
-      LEFT OUTER JOIN alias_domain ON target_domain=domain
-      GROUP BY username;",
-    "sieve_before" => "CREATE VIEW sieve_before (id, username, script_name, script_data) AS
-      SELECT md5(script_data), username, script_name, script_data FROM sieve_filters
-      WHERE filter_type = 'prefilter';",
-    "sieve_after" => "CREATE VIEW sieve_after (id, username, script_name, script_data) AS
-      SELECT md5(script_data), username, script_name, script_data FROM sieve_filters
-      WHERE filter_type = 'postfilter';"
+      "grouped_mail_aliases" => "CREATE VIEW grouped_mail_aliases (username, aliases) AS
+        SELECT goto, IFNULL(GROUP_CONCAT(address ORDER BY address SEPARATOR ' '), '') AS address FROM alias
+        WHERE address!=goto
+        AND active = '1'
+        AND sogo_visible = '1'
+        AND address NOT LIKE '@%'
+        GROUP BY goto;",
+      // START
+      // Unused at the moment - we cannot allow to show a foreign mailbox as sender address in SOGo, as SOGo does not like this
+      // We need to create delegation in SOGo AND set a sender_acl in mailcow to allow to send as user X
+      "grouped_sender_acl" => "CREATE VIEW grouped_sender_acl (username, send_as_acl) AS
+        SELECT logged_in_as, IFNULL(GROUP_CONCAT(send_as SEPARATOR ' '), '') AS send_as_acl FROM sender_acl
+        WHERE send_as NOT LIKE '@%'
+        GROUP BY logged_in_as;",
+      // END 
+      "grouped_sender_acl_external" => "CREATE VIEW grouped_sender_acl_external (username, send_as_acl) AS
+        SELECT logged_in_as, IFNULL(GROUP_CONCAT(send_as SEPARATOR ' '), '') AS send_as_acl FROM sender_acl
+        WHERE send_as NOT LIKE '@%' AND external = '1'
+        GROUP BY logged_in_as;",
+      "grouped_domain_alias_address" => "CREATE VIEW grouped_domain_alias_address (username, ad_alias) AS
+        SELECT username, IFNULL(GROUP_CONCAT(local_part, '@', alias_domain SEPARATOR ' '), '') AS ad_alias FROM mailbox
+        LEFT OUTER JOIN alias_domain ON target_domain=domain
+        GROUP BY username;",
+      "sieve_before" => "CREATE VIEW sieve_before (id, username, script_name, script_data) AS
+        SELECT md5(script_data), username, script_name, script_data FROM sieve_filters
+        WHERE filter_type = 'prefilter';",
+      "sieve_after" => "CREATE VIEW sieve_after (id, username, script_name, script_data) AS
+        SELECT md5(script_data), username, script_name, script_data FROM sieve_filters
+        WHERE filter_type = 'postfilter';"
     );
 
     $tables = array(
@@ -251,6 +251,26 @@ function init_db_schema() {
         ),
         "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
       ),
+      "tags_domain" => array(
+        "cols" => array(
+          "tag_name" => "VARCHAR(255) NOT NULL",
+          "domain" => "VARCHAR(255) NOT NULL"
+        ),
+        "keys" => array(
+          "fkey" => array(
+            "fk_tags_domain" => array(
+              "col" => "domain",
+              "ref" => "domain.domain",
+              "delete" => "CASCADE",
+              "update" => "NO ACTION"
+            )
+          ),
+          "unique" => array(
+            "tag_name" => array("tag_name", "domain")
+          )
+        ),
+        "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+      ),
       "tls_policy_override" => array(
         "cols" => array(
           "id" => "INT NOT NULL AUTO_INCREMENT",
@@ -325,6 +345,26 @@ function init_db_schema() {
         ),
         "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
       ),
+      "tags_mailbox" => array(
+        "cols" => array(
+          "tag_name" => "VARCHAR(255) NOT NULL",
+          "username" => "VARCHAR(255) NOT NULL"
+        ),
+        "keys" => array(
+          "fkey" => array(
+            "fk_tags_mailbox" => array(
+              "col" => "username",
+              "ref" => "mailbox.username",
+              "delete" => "CASCADE",
+              "update" => "NO ACTION"
+            )
+          ),
+          "unique" => array(
+            "tag_name" => array("tag_name", "username")
+          )
+        ),
+        "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+      ),
       "sieve_filters" => array(
         "cols" => array(
           "id" => "INT NOT NULL AUTO_INCREMENT",

+ 3 - 0
data/web/inc/vars.inc.php

@@ -148,6 +148,9 @@ $ACCESS_TOKEN_LIFETIME = 86400;
 // Logout from mailcow after first OAuth2 session profile request
 $OAUTH2_FORGET_SESSION_AFTER_LOGIN = false;
 
+// Set a limit for mailbox and domain tagging
+$TAGGING_LIMIT = 25;
+
 // MAILBOX_DEFAULT_ATTRIBUTES define default attributes for new mailboxes
 // These settings will not change existing mailboxes
 

+ 15 - 2
data/web/js/build/012-api.js

@@ -156,6 +156,12 @@ $(document).ready(function() {
       });
       if (!invalid) {
         var attr_to_merge = $(this).closest("form").serializeObject();
+        // parse possible JSON Strings
+        for (var [key, value] of Object.entries(attr_to_merge)) {
+          try {
+            attr_to_merge[key] = JSON.parse(attr_to_merge[key]);
+          } catch {}
+        }
         var api_attr = $.extend(api_attr, attr_to_merge)
       } else {
         return false;
@@ -263,6 +269,12 @@ $(document).ready(function() {
       });
       if (!invalid) {
         var attr_to_merge = $(this).closest("form").serializeObject();
+        // parse possible JSON Strings
+        for (var [key, value] of Object.entries(attr_to_merge)) {
+          try {
+            attr_to_merge[key] = JSON.parse(attr_to_merge[key]);
+          } catch {}
+        }
         var api_attr = $.extend(api_attr, attr_to_merge)
       } else {
         return false;
@@ -329,6 +341,7 @@ $(document).ready(function() {
       multi_data[id].splice($.inArray($(this).data('item'), multi_data[id]), 1);
       multi_data[id].push($(this).data('item'));
     }
+
     if (typeof $(this).data('text') !== 'undefined') {
       $("#DeleteText").empty();
       $("#DeleteText").text($(this).data('text'));
@@ -340,9 +353,9 @@ $(document).ready(function() {
       $("#ItemsToDelete").empty();
       for (var i in data_array) {
         data_array[i] = decodeURIComponent(data_array[i]);
-        $("#ItemsToDelete").append("<li>" + data_array[i] + "</li>");
+        $("#ItemsToDelete").append("<li>" + escapeHtml(data_array[i]) + "</li>");
       }
-    })
+    });
     $('#ConfirmDeleteModal').modal({
         backdrop: 'static',
         keyboard: false

+ 47 - 1
data/web/js/build/014-mailcow.js

@@ -48,7 +48,7 @@ $(document).ready(function() {
       $(div).animate({ left: ((iter%2==0 ? distance : distance*-1))}, interval);
     }
     $(div).animate({ left: 0},interval);
-  }
+  } 
 
   // form cache
   $('[data-cached-form="true"]').formcache({key: $(this).data('id')});
@@ -273,4 +273,50 @@ $(document).ready(function() {
         }
       }
   });
+
+  // tag boxes
+  $('.tag-box .tag-add').click(function(){
+    addTag(this);
+  });
+  $(".tag-box .tag-input").keydown(function (e) {
+    if (e.which == 13){
+      e.preventDefault();
+      addTag(this);
+    } 
+  });
+  function addTag(tagAddElem){
+    var tagboxElem = $(tagAddElem).parent();
+    var tagInputElem = $(tagboxElem).find(".tag-input")[0];
+    var tagValuesElem = $(tagboxElem).find(".tag-values")[0];
+
+    var tag = escapeHtml($(tagInputElem).val());
+    var value_tags = [];
+    try {
+      value_tags = JSON.parse($(tagValuesElem).val());
+    } catch {}
+    if (!Array.isArray(value_tags)) value_tags = [];
+    if (value_tags.includes(tag)) return;
+
+    $('<span class="badge badge-primary tag-badge btn-badge"><i class="bi bi-tag-fill"></i> ' + tag + '</span>').insertBefore('.tag-input').click(function(){
+      var del_tag = unescapeHtml($(this).text());
+      var del_tags = [];
+      try {
+        del_tags = JSON.parse($(tagValuesElem).val());
+      } catch {}
+      if (Array.isArray(del_tags)){
+        del_tags.splice(del_tags.indexOf(del_tag), 1);
+        $(tagValuesElem).val(JSON.stringify(del_tags));
+      }
+      $(this).remove();
+    });
+
+    value_tags.push($(tagInputElem).val());
+    $(tagValuesElem).val(JSON.stringify(value_tags));
+    $(tagInputElem).val('');
+  }
 });
+
+
+// http://stackoverflow.com/questions/24816/escaping-html-strings-with-jquery
+function escapeHtml(n){var entityMap={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;","/":"&#x2F;","`":"&#x60;","=":"&#x3D;"}; return String(n).replace(/[&<>"'`=\/]/g,function(n){return entityMap[n]})}
+function unescapeHtml(t){var n={"&amp;":"&","&lt;":"<","&gt;":">","&quot;":'"',"&#39;":"'","&#x2F;":"/","&#x60;":"`","&#x3D;":"="};return String(t).replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&#x2F|&#x60|&#x3D;/g,function(t){return n[t]})}

+ 16 - 3
data/web/js/site/mailbox.js

@@ -236,9 +236,6 @@ $(document).ready(function() {
 
 });
 jQuery(function($){
-  // http://stackoverflow.com/questions/24816/escaping-html-strings-with-jquery
-  var entityMap={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;","/":"&#x2F;","`":"&#x60;","=":"&#x3D;"};
-  function escapeHtml(n){return String(n).replace(/[&<>"'`=\/]/g,function(n){return entityMap[n]})}
   // http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
   function humanFileSize(i){if(Math.abs(i)<1024)return i+" B";var B=["KiB","MiB","GiB","TiB","PiB","EiB","ZiB","YiB"],e=-1;do{i/=1024,++e}while(Math.abs(i)>=1024&&e<B.length-1);return i.toFixed(1)+" "+B[e]}
   function unix_time_format(i){return""==i?'<i class="bi bi-x-lg"></i>':new Date(i?1e3*i:0).toLocaleDateString(void 0,{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}
@@ -293,6 +290,7 @@ jQuery(function($){
         {"name":"rl","title":"RL","breakpoints":"xs sm md lg","style":{"min-width":"100px","width":"100px"}},
         {"name":"backupmx","filterable": false,"style":{"min-width":"120px","width":"120px"},"title":lang.backup_mx,"breakpoints":"xs sm md lg","formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':0==value&&'<i class="bi bi-x-lg"></i>';}},
         {"name":"domain_admins","title":lang.domain_admins,"style":{"word-break":"break-all","min-width":"200px"},"breakpoints":"xs sm md lg","filterable":(role == "admin"),"visible":(role == "admin")},
+        {"name":"tags","title":"Tags","style":{},"breakpoints":"xs sm md lg"},
         {"name":"active","filterable": false,"style":{"min-width":"80px","width":"80px"},"title":lang.active,"formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':0==value&&'<i class="bi bi-x-lg"></i>';}},
         {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","min-width":"240px","width":"240px"},"type":"html","title":lang.action,"breakpoints":"xs sm md"}
       ],
@@ -330,6 +328,13 @@ jQuery(function($){
               '<a href="#dnsInfoModal" class="btn btn-xs btn-xs-half btn-info" data-toggle="modal" data-domain="' + encodeURIComponent(item.domain_name) + '"><i class="bi bi-globe2"></i> DNS</a></div>';
             }
 
+            if (Array.isArray(item.tags)){
+              var tags = '';
+              for (var i = 0; i < item.tags.length; i++)
+                tags += '<span class="badge badge-primary tag-badge"><i class="bi bi-tag-fill"></i> ' + escapeHtml(item.tags[i]) + '</span>';
+              item.tags = tags;
+            }
+
             if (item.backupmx == 1) {
               if (item.relay_unknown_only == 1) {
                 item.domain_name = '<div class="label label-info">Relay Non-Local</div> ' + item.domain_name;
@@ -418,6 +423,7 @@ jQuery(function($){
         },
         {"name":"messages","filterable": false,"title":lang.msg_num,"breakpoints":"xs sm md"},
         /* {"name":"rl","title":"RL","breakpoints":"all","style":{"width":"125px"}}, */
+        {"name":"tags","title":"Tags","style":{},"breakpoints":"xs sm md lg"},
         {"name":"active","filterable": false,"style":{"min-width":"80px","width":"80px"},"title":lang.active,"formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':(0==value?'<i class="bi bi-x-lg"></i>':2==value&&'&#8212;');}},
         {"name":"action","filterable": false,"sortable": false,"style":{"min-width":"290px","text-align":"right"},"type":"html","title":lang.action,"breakpoints":"xs sm md"}
       ],
@@ -497,6 +503,13 @@ jQuery(function($){
               '<div class="progress-bar-mailbox progress-bar progress-bar-' + item.percent_class + '" role="progressbar" aria-valuenow="' + item.percent_in_use + '" aria-valuemin="0" aria-valuemax="100" ' +
               'style="min-width:2em;width:' + item.percent_in_use + '%">' + item.percent_in_use + '%' + '</div></div>';
             item.username = escapeHtml(item.username);
+            
+            if (Array.isArray(item.tags)){
+              var tags = '';
+              for (var i = 0; i < item.tags.length; i++)
+                tags += '<span class="badge badge-primary tag-badge"><i class="bi bi-tag-fill"></i> ' + escapeHtml(item.tags[i]) + '</span>';
+              item.tags = tags;
+            }
           });
         }
       }),

+ 49 - 22
data/web/json_api.php

@@ -14,17 +14,20 @@ function api_log($_data) {
     if ($data == 'csrf_token') {
       continue;
     }
-    if ($value = json_decode($value, true)) {
-      unset($value["csrf_token"]);
+
+    $value = json_decode($value, true);     
+    if ($value) {
+      if (is_array($value)) unset($value["csrf_token"]);
       foreach ($value as $key => &$val) {
         if(preg_match("/pass/i", $key)) {
           $val = '*';
         }
       }
-      $value = json_encode($value);
+      $value = json_encode($value);  
     }
     $data_var[] = $data . "='" . $value . "'";
   }
+
   try {
     $log_line = array(
       'time' => time(),
@@ -41,7 +44,7 @@ function api_log($_data) {
       'msg' => 'Redis: '.$e
     );
     return false;
-  }
+  }     
 }
 
 if (isset($_GET['query'])) {
@@ -82,10 +85,10 @@ if (isset($_GET['query'])) {
     if ($action == 'delete') {
       $_POST['items'] = $request;
     }
-
   }
   api_log($_POST);
 
+
   $request_incomplete = json_encode(array(
     'type' => 'error',
     'msg' => 'Cannot find attributes in post data'
@@ -486,7 +489,12 @@ if (isset($_GET['query'])) {
           case "domain":
             switch ($object) {
               case "all":
-                $domains = mailbox('get', 'domains');
+                $tags = null;
+                if (isset($_GET['tags']) && $_GET['tags'] != '') 
+                  $tags = explode(',', $_GET['tags']);
+
+                $domains = mailbox('get', 'domains', null, $tags);
+
                 if (!empty($domains)) {
                   foreach ($domains as $domain) {
                     if ($details = mailbox('get', 'domain_details', $domain)) {
@@ -952,23 +960,20 @@ if (isset($_GET['query'])) {
             switch ($object) {
               case "all":
               case "reduced":
-                if (empty($extra)) {
-                  $domains = mailbox('get', 'domains');
-                }
-                else {
-                  $domains = explode(',', $extra);
-                }
+                $tags = null;
+                if (isset($_GET['tags']) && $_GET['tags'] != '') 
+                  $tags = explode(',', $_GET['tags']);
+
+                if (empty($extra)) $domains = mailbox('get', 'domains');
+                else $domains = explode(',', $extra);
+
                 if (!empty($domains)) {
                   foreach ($domains as $domain) {
-                    $mailboxes = mailbox('get', 'mailboxes', $domain);
+                    $mailboxes = mailbox('get', 'mailboxes', $domain, $tags);
                     if (!empty($mailboxes)) {
                       foreach ($mailboxes as $mailbox) {
-                        if ($details = mailbox('get', 'mailbox_details', $mailbox, $object)) {
-                          $data[] = $details;
-                        }
-                        else {
-                          continue;
-                        }
+                        if ($details = mailbox('get', 'mailbox_details', $mailbox, $object)) $data[] = $details;
+                        else continue;
                       }
                     }
                   }
@@ -980,7 +985,17 @@ if (isset($_GET['query'])) {
               break;
 
               default:
-                $data = mailbox('get', 'mailbox_details', $object);
+                $tags = null;
+                if (isset($_GET['tags']) && $_GET['tags'] != '') 
+                  $tags = explode(',', $_GET['tags']);
+
+                $mailboxes = mailbox('get', 'mailboxes', $object, $tags);
+                if (!empty($mailboxes)) {
+                  foreach ($mailboxes as $mailbox) {
+                    if ($details = mailbox('get', 'mailbox_details', $mailbox)) $data[] = $details;
+                    else continue;
+                  }
+                }
                 process_get_return($data);
               break;
             }
@@ -1580,13 +1595,25 @@ if (isset($_GET['query'])) {
           process_delete_return(dkim('delete', array('domains' => $items)));
         break;
         case "domain":
-          process_delete_return(mailbox('delete', 'domain', array('domain' => $items)));
+          switch ($object){
+            case "tag":
+              process_delete_return(mailbox('delete', 'tags_domain', array('tags' => $items, 'domain' => $extra)));
+            break;
+            default:
+              process_delete_return(mailbox('delete', 'domain', array('domain' => $items)));
+          }
         break;
         case "alias-domain":
           process_delete_return(mailbox('delete', 'alias_domain', array('alias_domain' => $items)));
         break;
         case "mailbox":
-          process_delete_return(mailbox('delete', 'mailbox', array('username' => $items)));
+          switch ($object){
+            case "tag":
+              process_delete_return(mailbox('delete', 'tags_mailbox', array('tags' => $items, 'username' => $extra)));
+            break;
+            default:
+              process_delete_return(mailbox('delete', 'mailbox', array('username' => $items)));
+          }
         break;
         case "resource":
           process_delete_return(mailbox('delete', 'resource', array('name' => $items)));

+ 1 - 0
data/web/lang/lang.en.json

@@ -99,6 +99,7 @@
         "subscribeall": "Subscribe all folders",
         "syncjob": "Add sync job",
         "syncjob_hint": "Be aware that passwords need to be saved plain-text!",
+        "tags": "Tags",
         "target_address": "Goto addresses",
         "target_address_info": "<small>Full email address/es (comma-separated).</small>",
         "target_domain": "Target domain",

+ 16 - 0
data/web/templates/edit/domain.twig

@@ -23,6 +23,22 @@
           <input type="text" class="form-control" name="description" value="{{ result.description }}">
         </div>
       </div>
+      <div class="form-group">
+        <label class="control-label col-sm-2">{{ lang.add.tags }}</label>
+        <div class="col-sm-10">
+          <div class="form-control tag-box">
+            {% for tag in domain_details.tags %}
+              <span data-action='delete_selected' data-item="{{ tag|url_encode }}" data-id="domain_tag_{{ tag }}" data-api-url='delete/domain/tag/{{ domain }}' class="badge badge-primary tag-badge btn-badge">
+                <i class="bi bi-tag-fill"></i> 
+                {{ tag }}
+              </span>
+            {% endfor %}
+            <input type="text" class="tag-input">
+            <span class="btn tag-add"><i class="bi bi-plus-lg"></i></span>
+            <input type="hidden" value="" name="tags" class="tag-values" />
+          </div>
+        </div>
+      </div>
       <div class="form-group">
         <label class="control-label col-sm-2" for="relayhost">{{ lang.edit.relayhost }}</label>
         <div class="col-sm-10">

+ 16 - 0
data/web/templates/edit/mailbox.twig

@@ -22,6 +22,22 @@
           <input type="text" class="form-control" name="name" value="{{ result.name }}">
         </div>
       </div>
+      <div class="form-group">
+        <label class="control-label col-sm-2">{{ lang.add.tags }}</label>
+        <div class="col-sm-10">
+          <div class="form-control tag-box">
+            {% for tag in mailbox_details.tags %}
+              <span data-action='delete_selected' data-item="{{ tag }}" data-id="mailbox_tag_{{ tag }}" data-api-url='delete/mailbox/tag/{{ mailbox }}' class="badge badge-primary tag-badge btn-badge">
+                <i class="bi bi-tag-fill"></i> 
+                {{ tag }}
+              </span>
+            {% endfor %}
+            <input type="text" class="tag-input">
+            <span class="btn tag-add"><i class="bi bi-plus-lg"></i></span>
+            <input type="hidden" value="" name="tags" class="tag-values" />
+          </div>
+        </div>
+      </div>
       <div class="form-group">
         <label class="control-label col-sm-2" for="quota">{{ lang.edit.quota_mb }}
           <br><span id="quotaBadge" class="badge">max. {{ (result.max_new_quota / 1048576) }} MiB</span>

+ 23 - 3
data/web/templates/modals/mailbox.twig

@@ -30,6 +30,16 @@
               <input type="text" class="form-control" name="name">
             </div>
           </div>
+          <div class="form-group">
+            <label class="control-label col-sm-2">{{ lang.add.tags }}</label>
+            <div class="col-sm-10">
+              <div class="form-control tag-box">
+                <input type="text" class="tag-input">
+                <span class="btn tag-add"><i class="bi bi-plus-lg"></i></span>
+                <input type="hidden" value="" name="tags" class="tag-values" />
+              </div>
+            </div>
+          </div>
           <div class="form-group">
             <label class="control-label col-sm-2" for="addInputQuota">{{ lang.add.quota_mb }}
               <br /><span id="quotaBadge" class="badge">max. - MiB</span>
@@ -94,6 +104,16 @@
               <input type="text" class="form-control" name="description">
             </div>
           </div>
+          <div class="form-group">
+            <label class="control-label col-sm-2">{{ lang.add.tags }}</label>
+            <div class="col-sm-10">
+              <div class="form-control tag-box">
+                <input type="text" class="tag-input">
+                <span class="btn tag-add"><i class="bi bi-plus-lg"></i></span>
+                <input type="hidden" value="" name="tags" class="tag-values" />
+              </div>
+            </div>
+          </div>
           <div class="form-group">
             <label class="control-label col-sm-2" for="aliases">{{ lang.add.max_aliases }}</label>
             <div class="col-sm-10">
@@ -188,11 +208,11 @@
           <div class="form-group">
             <div class="col-sm-offset-2 col-sm-10 btn-group">
               {% if not skip_sogo %}
-              <button class="btn btn-xs-lg btn-xs-half visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-default" data-action="add_item" data-id="add_domain" data-api-url='add/domain' data-api-attr='{}' href="#">{{ lang.add.add_domain_only }}</button>
-              <button class="btn btn-xs-lg btn-xs-half visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-default" data-action="add_item" data-id="add_domain" data-api-url='add/domain' data-api-attr='{"restart_sogo":"1"}' href="#">{{ lang.add.add_domain_restart }}</button>
+              <button class="btn btn-xs-lg btn-xs-half visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-default" data-action="add_item" data-id="add_domain" data-api-url='add/domain' data-api-attr='{"tags": []}' href="#">{{ lang.add.add_domain_only }}</button>
+              <button class="btn btn-xs-lg btn-xs-half visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-default" data-action="add_item" data-id="add_domain" data-api-url='add/domain' data-api-attr='{"restart_sogo":"1", "tags": []}' href="#">{{ lang.add.add_domain_restart }}</button>
               <div class="clearfix visible-xs"></div>
               {% else %}
-              <button class="btn btn-xs-lg visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-success" data-action="add_item" data-id="add_domain" data-api-url='add/domain' data-api-attr='{}' href="#">{{ lang.add.add }}</button>
+              <button class="btn btn-xs-lg visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-success" data-action="add_item" data-id="add_domain" data-api-url='add/domain' data-api-attr='{"tags": []}' href="#">{{ lang.add.add }}</button>
               {% endif %}
             </div>
           </div>