NobGit
public nobgit read

NobMail

Based on mailcow: dockerized

Languages

Repository composition by tracked source files.

PHP
PHP 49% JavaScript 35% HTML 9% CSS 4% Shell 2% Python 1% Lua 0% Perl 0% Ruby 0% SCSS 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/nobgit/nobmail.git
ssh://[email protected]:2222/orgs/nobgit/nobmail.git

Commit

[Web, Postfix, Compose] Allow to add relayhosts per domain (+ plain and login authentication)

83d485dd
andryyy <[email protected]> 9 years ago
data/Dockerfiles/postfix/postfix.sh      |  19 +++-
 data/conf/postfix/main.cf                |   9 +-
 data/web/admin.php                       |  56 ++++++++++
 data/web/css/admin.css                   |  13 ++-
 data/web/edit.php                        |  16 +++
 data/web/inc/functions.mailbox.inc.php   |  11 +-
 data/web/inc/functions.relayhost.inc.php | 179 +++++++++++++++++++++++++++++++
 data/web/inc/header.inc.php              |   2 +-
 data/web/inc/init_db.inc.php             |  22 +++-
 data/web/inc/prerequisites.inc.php       |   1 +
 data/web/js/admin.js                     |  53 +++++++++
 data/web/json_api.php                    | 154 ++++++++++++++++++++++++++
 data/web/lang/lang.de.php                |   7 ++
 data/web/lang/lang.en.php                |   7 ++
 docker-compose.yml                       |   2 +-
 15 files changed, 538 insertions(+), 13 deletions(-)
 create mode 100644 data/web/inc/functions.relayhost.inc.php

Diff

diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh
index 640538b0..9837eeef 100755
--- a/data/Dockerfiles/postfix/postfix.sh
+++ b/data/Dockerfiles/postfix/postfix.sh
@@ -20,12 +20,26 @@ dbname = ${DBNAME}
 query = SELECT IF( EXISTS( SELECT 'TLS_ACTIVE' FROM alias LEFT OUTER JOIN mailbox ON mailbox.username = alias.goto WHERE (address='%s' OR address IN (SELECT CONCAT('%u', '@', target_domain) FROM alias_domain WHERE alias_domain='%d')) AND mailbox.tls_enforce_in = '1' AND mailbox.active = '1'), 'reject_plaintext_session', NULL) AS 'tls_enforce_in';
 EOF
 
-cat <<EOF > /opt/postfix/conf/sql/mysql_tls_enforce_out_policy.cf
+cat <<EOF > /opt/postfix/conf/sql/mysql_sender_dependent_default_transport_maps.cf
 user = ${DBUSER}
 password = ${DBPASS}
 hosts = mysql
 dbname = ${DBNAME}
-query = SELECT IF( EXISTS( SELECT 'TLS_ACTIVE' FROM alias LEFT OUTER JOIN mailbox ON mailbox.username = alias.goto WHERE (address='%s' OR address IN (SELECT CONCAT('%u', '@', target_domain) FROM alias_domain WHERE alias_domain='%d')) AND mailbox.tls_enforce_out = '1' AND mailbox.active = '1'), 'smtp_enforced_tls:', NULL) AS 'tls_enforce_out';
+query = SELECT GROUP_CONCAT(transport SEPARATOR '') AS transport_maps
+  FROM (
+    SELECT IF(EXISTS(SELECT 'smtp_type' FROM alias LEFT OUTER JOIN mailbox ON mailbox.username = alias.goto WHERE (address = '%s' OR address IN (SELECT CONCAT('%u', '@', target_domain) FROM alias_domain WHERE alias_domain = '%d')) AND mailbox.tls_enforce_out = '1' AND mailbox.active = '1'), 'smtp_enforced_tls:', 'smtp:') AS 'transport'
+    UNION ALL
+    SELECT hostname AS transport FROM relayhosts LEFT OUTER JOIN domain ON domain.relayhost = relayhosts.id WHERE relayhosts.active = '1' AND domain = '%d' OR domain IN (SELECT target_domain FROM alias_domain WHERE alias_domain = '%d')
+  )
+  AS transport_view;
+EOF
+
+cat <<EOF > /opt/postfix/conf/sql/mysql_sasl_passwd_maps.cf
+user = ${DBUSER}
+password = ${DBPASS}
+hosts = mysql
+dbname = ${DBNAME}
+query = SELECT CONCAT_WS(':', username, password) AS auth_data FROM relayhosts WHERE id IN (SELECT relayhost FROM domain WHERE CONCAT('@', domain) = '%s');
 EOF
 
 cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_alias_domain_catchall_maps.cf
@@ -110,6 +124,5 @@ if [[ $? != 0 ]]; then
 	exit 1
 else
 	postfix -c /opt/postfix/conf start
-	supervisorctl restart postfix-maillog
 	sleep 126144000
 fi
diff --git a/data/conf/postfix/main.cf b/data/conf/postfix/main.cf
index 82bfae85..fe6fd3ab 100644
--- a/data/conf/postfix/main.cf
+++ b/data/conf/postfix/main.cf
@@ -39,11 +39,11 @@ postscreen_greet_ttl = 2d
 postscreen_greet_wait = 3s
 postscreen_non_smtp_command_enable = no
 postscreen_pipelining_enable = no
-proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_virtual_sender_acl.cf, proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_out_policy.cf, proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf, $local_recipient_maps $mydestination $virtual_alias_maps $virtual_alias_domains $virtual_mailbox_maps $virtual_mailbox_domains $relay_recipient_maps $relay_domains $canonical_maps $sender_canonical_maps $recipient_canonical_maps $relocated_maps $transport_maps $mynetworks $smtpd_sender_login_maps
+proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_virtual_sender_acl.cf, proxy:mysql:/opt/postfix/conf/sql/mysql_sender_dependent_default_transport_maps.cf, proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps.cf, proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf, $local_recipient_maps $mydestination $virtual_alias_maps $virtual_alias_domains $virtual_mailbox_maps $virtual_mailbox_domains $relay_recipient_maps $relay_domains $canonical_maps $sender_canonical_maps $recipient_canonical_maps $relocated_maps $transport_maps $mynetworks $smtpd_sender_login_maps
 queue_run_delay = 300s
 relay_domains = proxy:mysql:/opt/postfix/conf/sql/mysql_virtual_relay_domain_maps.cf
 relay_recipient_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_relay_recipient_maps.cf
-sender_dependent_default_transport_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_out_policy.cf
+sender_dependent_default_transport_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sender_dependent_default_transport_maps.cf
 smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
 smtp_tls_cert_file = /etc/ssl/mail/cert.pem
 smtp_tls_key_file = /etc/ssl/mail/key.pem
@@ -94,3 +94,8 @@ mydestination = localhost.localdomain, localhost
 #content_filter=zeyple
 # Prefere IPv4, useful for v4-only envs
 smtp_address_preference = ipv4
+smtp_sender_dependent_authentication = yes
+smtp_sasl_auth_enable = yes
+smtp_sasl_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps.cf
+smtp_sasl_security_options = 
+smtp_sasl_mechanism_filter = plain, login
diff --git a/data/web/admin.php b/data/web/admin.php
index 284bf31b..bb53e61e 100644
--- a/data/web/admin.php
+++ b/data/web/admin.php
@@ -121,6 +121,18 @@ $tfa_data = get_tfa();
 
 
   <div role="tabpanel" class="tab-pane" id="tab-config">
+    <div class="row">
+    <div class="col-sm-2 hidden-xs">
+      <div id="scrollbox" class="list-group">
+        <a href="#dkim" class="list-group-item"><?=$lang['admin']['dkim_keys'];?></a>
+        <a href="#fwdhosts" class="list-group-item"><?=$lang['admin']['forwarding_hosts'];?></a>
+        <a href="#f2bparams" class="list-group-item"><?=$lang['admin']['f2b_parameters'];?></a>
+        <a href="#relayhosts" class="list-group-item">Relayhosts</a>
+        <a href="#top" class="list-group-item" style="border-top:1px dashed #dadada">↸ <?=$lang['admin']['to_top'];?></a>
+      </div>
+    </div>
+    <div class="col-sm-10">
+    <span class="anchor" id="dkim"></span>
     <div class="panel panel-default">
       <div class="panel-heading"><?=$lang['admin']['dkim_keys'];?></div>
       <div class="panel-body">
@@ -254,6 +266,7 @@ XYZ
       </div>
     </div>
 
+    <span class="anchor" id="fwdhosts"></span>
     <div class="panel panel-default">
       <div class="panel-heading"><?=$lang['admin']['forwarding_hosts'];?></div>
       <div class="panel-body">
@@ -291,6 +304,7 @@ XYZ
       </div>
     </div>
 
+    <span class="anchor" id="f2bparams"></span>
     <div class="panel panel-default">
       <div class="panel-heading"><?=$lang['admin']['f2b_parameters'];?></div>
       <div class="panel-body">
@@ -318,6 +332,48 @@ XYZ
         </form>
       </div>
     </div>
+
+    <span class="anchor" id="relayhosts"></span>
+    <div class="panel panel-default">
+      <div class="panel-heading">Relayhosts</div>
+      <div class="panel-body">
+        <p style="margin-bottom:40px"><?=$lang['admin']['relayhosts_hint'];?></p>
+        <div class="table-responsive">
+          <table class="table table-striped table-condensed" id="relayhoststable"></table>
+        </div>
+        <div class="mass-actions-admin">
+          <div class="btn-group btn-group-sm">
+            <button type="button" id="toggle_multi_select_all" data-id="rlyhosts" class="btn btn-default"><?=$lang['mailbox']['toggle_all'];?></button>
+            <a class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown" href="#"><?=$lang['mailbox']['quick_actions'];?> <span class="caret"></span></a>
+            <ul class="dropdown-menu">
+              <li><a id="edit_selected" data-id="rlyhosts" data-api-url='edit/relayhost' data-api-attr='{"active":"1"}' href="#"><?=$lang['mailbox']['activate'];?></a></li>
+              <li><a id="edit_selected" data-id="rlyhosts" data-api-url='edit/relayhost' data-api-attr='{"active":"0"}' href="#"><?=$lang['mailbox']['deactivate'];?></a></li>
+              <li role="separator" class="divider"></li>
+              <li><a id="delete_selected" data-id="rlyhosts" data-api-url='delete/relayhost' href="#"><?=$lang['admin']['remove'];?></a></li>
+            </ul>
+          </div>
+        </div>
+        <legend><?=$lang['admin']['add_relayhost'];?></legend>
+        <p class="help-block"><?=$lang['admin']['add_relayhost_add_hint'];?></p>
+        <form class="form-inline" data-id="rlyhost" role="form" method="post">
+          <div class="form-group">
+            <label for="hostname"><?=$lang['admin']['host'];?></label>
+            <input class="form-control" id="hostname" name="hostname" required>
+          </div>
+          <div class="form-group">
+            <label for="hostname"><?=$lang['admin']['username'];?></label>
+            <input class="form-control" id="username" name="username">
+          </div>
+          <div class="form-group">
+            <label for="hostname"><?=$lang['admin']['password'];?></label>
+            <input class="form-control" id="password" name="password">
+          </div>
+          <button class="btn btn-default" id="add_item" data-id="rlyhost" data-api-url='add/relayhost' data-api-attr='{}' href="#"><span class="glyphicon glyphicon-plus"></span> <?=$lang['admin']['add'];?></button>
+        </form>
+      </div>
+    </div>
+  </div>
+  </div>
   </div>
 
   <div role="tabpanel" class="tab-pane" id="tab-postfix-logs">
diff --git a/data/web/css/admin.css b/data/web/css/admin.css
index 7f8897a0..aa07d7a3 100644
--- a/data/web/css/admin.css
+++ b/data/web/css/admin.css
@@ -41,4 +41,15 @@ body.modal-open {
   -moz-transform:rotateX(180deg);
   -webkit-transform:rotateX(180deg);
   transform:rotateX(180deg);
-}
\ No newline at end of file
+}
+.anchor {
+  display: block;
+  height: 65px;
+  margin-top: -65px;
+  visibility: hidden;
+}
+.scrollboxFixed {
+  position: fixed;
+  top: 65px;
+  z-index: 1;
+}
diff --git a/data/web/edit.php b/data/web/edit.php
index 1f250535..eb0eb375 100644
--- a/data/web/edit.php
+++ b/data/web/edit.php
@@ -138,6 +138,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
 			$domain = $_GET["domain"];
       $result = mailbox('get', 'domain_details', $domain);
       $rl = mailbox('get', 'domain_ratelimit', $domain);
+      $rlyhosts = relayhost('get');
 			if (!empty($result)) {
 			?>
 				<h4><?=$lang['edit']['domain'];?></h4>
@@ -178,6 +179,21 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
 							<input type="number" class="form-control" name="quota" id="quota" value="<?=intval($result['max_quota_for_domain'] / 1048576);?>">
 						</div>
 					</div>
+					<div class="form-group">
+						<label class="control-label col-sm-2" for="quota">Relayhost</label>
+						<div class="col-sm-10">
+              <select name="relayhost" id="relayhost" class="form-control">
+                <?php
+                foreach ($rlyhosts as $rlyhost) {
+                ?>
+                <option value="<?=$rlyhost['id'];?>" <?=($result['relayhost'] == $rlyhost['id']) ? 'selected' : null;?>>ID <?=$rlyhost['id'];?>: <?=$rlyhost['hostname'];?> (<?=$rlyhost['username'];?>)</option>
+                <?php
+                }
+                ?>
+                <option value="" <?=($result['relayhost'] == "0") ? 'selected' : null;?>>None</option>
+              </select>
+            </div>
+					</div>
 					<div class="form-group">
 						<label class="control-label col-sm-2"><?=$lang['edit']['backup_mx_options'];?></label>
 						<div class="col-sm-10">
diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php
index c81f3a88..3f302783 100644
--- a/data/web/inc/functions.mailbox.inc.php
+++ b/data/web/inc/functions.mailbox.inc.php
@@ -260,8 +260,8 @@ function mailbox($_action, $_type, $_data = null) {
             return false;
           }
           try {
-            $stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `maxquota`, `quota`, `transport`, `backupmx`, `active`, `relay_all_recipients`)
-              VALUES (:domain, :description, :aliases, :mailboxes, :maxquota, :quota, 'virtual', :backupmx, :active, :relay_all_recipients)");
+            $stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `maxquota`, `quota`, `backupmx`, `active`, `relay_all_recipients`)
+              VALUES (:domain, :description, :aliases, :mailboxes, :maxquota, :quota, :backupmx, :active, :relay_all_recipients)");
             $stmt->execute(array(
               ':domain' => $domain,
               ':description' => $description,
@@ -1441,6 +1441,7 @@ function mailbox($_action, $_type, $_data = null) {
                 $active               = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active_int'];
                 $backupmx             = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx_int'];
                 $relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $is_now['relay_all_recipients_int'];
+                $relayhost            = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : $is_now['relayhost'];
                 $aliases              = (!empty($_data['aliases'])) ? $_data['aliases'] : $is_now['max_num_aliases_for_domain'];
                 $mailboxes            = (!empty($_data['mailboxes'])) ? $_data['mailboxes'] : $is_now['max_num_mboxes_for_domain'];
                 $maxquota             = (!empty($_data['maxquota'])) ? $_data['maxquota'] : ($is_now['max_quota_for_mbox'] / 1048576);
@@ -1531,6 +1532,7 @@ function mailbox($_action, $_type, $_data = null) {
                 `active` = :active,
                 `quota` = :quota,
                 `maxquota` = :maxquota,
+                `relayhost` = :relayhost,
                 `mailboxes` = :mailboxes,
                 `aliases` = :aliases,
                 `description` = :description
@@ -1541,6 +1543,7 @@ function mailbox($_action, $_type, $_data = null) {
                   ':active' => $active,
                   ':quota' => $quota,
                   ':maxquota' => $maxquota,
+                  ':relayhost' => $relayhost,
                   ':mailboxes' => $mailboxes,
                   ':aliases' => $aliases,
                   ':description' => $description,
@@ -2470,7 +2473,7 @@ function mailbox($_action, $_type, $_data = null) {
               ':domain' => $_data
             ));
             $row = $stmt->fetch(PDO::FETCH_ASSOC);
-            if (!empty($row)) { 
+            if (!empty($row)) {
               $_data = $row['target_domain'];
             }
             $stmt = $pdo->prepare("SELECT 
@@ -2480,6 +2483,7 @@ function mailbox($_action, $_type, $_data = null) {
                 `mailboxes`, 
                 `maxquota`,
                 `quota`,
+                `relayhost`,
                 `relay_all_recipients` as `relay_all_recipients_int`,
                 `backupmx` as `backupmx_int`,
                 `active` as `active_int`,
@@ -2514,6 +2518,7 @@ function mailbox($_action, $_type, $_data = null) {
             $domaindata['max_num_mboxes_for_domain'] = $row['mailboxes'];
             $domaindata['max_quota_for_mbox'] = $row['maxquota'] * 1048576;
             $domaindata['max_quota_for_domain'] = $row['quota'] * 1048576;
+            $domaindata['relayhost'] = $row['relayhost'];
             $domaindata['backupmx'] = $row['backupmx'];
             $domaindata['backupmx_int'] = $row['backupmx_int'];
             $domaindata['active'] = $row['active'];
diff --git a/data/web/inc/functions.relayhost.inc.php b/data/web/inc/functions.relayhost.inc.php
new file mode 100644
index 00000000..249dacc3
--- /dev/null
+++ b/data/web/inc/functions.relayhost.inc.php
@@ -0,0 +1,179 @@
+<?php
+function relayhost($_action, $_data = null) {
+	global $pdo;
+	global $lang;
+  switch ($_action) {
+    case 'add':
+      if ($_SESSION['mailcow_cc_role'] != "admin") {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => sprintf($lang['danger']['access_denied'])
+        );
+        return false;
+      }
+      $hostname = trim($_data['hostname']);
+      $username = str_replace(':', '\:', trim($_data['username']));
+      $password = str_replace(':', '\:', trim($_data['password']));
+      if (empty($hostname)) {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => 'Invalid host specified: '. htmlspecialchars($host)
+        );
+        return false;
+      }
+      try {
+        $stmt = $pdo->prepare("INSERT INTO `relayhosts` (`hostname`, `username` ,`password`, `active`)
+          VALUES (:hostname, :username, :password, :active)");
+        $stmt->execute(array(
+          ':hostname' => $hostname,
+          ':username' => $username,
+          ':password' => $password,
+          ':active' => '1'
+        ));
+      }
+      catch (PDOException $e) {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => 'MySQL: '.$e
+        );
+        return false;
+      }
+      $_SESSION['return'] = array(
+        'type' => 'success',
+        'msg' => sprintf($lang['success']['relayhost_added'], htmlspecialchars(implode(', ', $hosts)))
+      );
+    break;
+    case 'edit':
+      if ($_SESSION['mailcow_cc_role'] != "admin") {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => sprintf($lang['danger']['access_denied'])
+        );
+        return false;
+      }
+      $ids = (array)$_data['id'];
+      foreach ($ids as $id) {
+        $is_now = relayhost('details', $id);
+        if (!empty($is_now)) {
+          $hostname = (!empty($_data['hostname'])) ? trim($_data['hostname']) : $is_now['hostname'];
+          $username = (!empty($_data['username'])) ? trim($_data['username']) : $is_now['username'];
+          $password = (!empty($_data['password'])) ? trim($_data['password']) : $is_now['password'];
+          $active   = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active_int'];
+        }
+        else {
+          $_SESSION['return'] = array(
+            'type' => 'danger',
+            'msg' => 'Relayhost invalid'
+          );
+          return false;
+        }
+        try {
+          $stmt = $pdo->prepare("UPDATE `relayhosts` SET
+            `hostname` = :hostname,
+            `username` = :username,
+            `password` = :password,
+            `active` = :active
+              WHERE `id` = :id");
+          $stmt->execute(array(
+            ':id' => $id,
+            ':hostname' => $hostname,
+            ':username' => $username,
+            ':password' => $password,
+            ':active' => $active
+          ));
+        }
+        catch (PDOException $e) {
+          $_SESSION['return'] = array(
+            'type' => 'danger',
+            'msg' => 'MySQL: '.$e
+          );
+          return false;
+        }
+      }
+      $_SESSION['return'] = array(
+        'type' => 'success',
+        'msg' => sprintf($lang['success']['object_modified'], htmlspecialchars(implode(', ', $hostnames)))
+      );
+    break;
+    case 'delete':
+      if ($_SESSION['mailcow_cc_role'] != "admin") {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => sprintf($lang['danger']['access_denied'])
+        );
+        return false;
+      }
+      $ids = (array)$_data['id'];
+      foreach ($ids as $id) {
+        try {
+          $stmt = $pdo->prepare("DELETE FROM `relayhosts` WHERE `id`= :id");
+          $stmt->execute(array(':id' => $id));
+          $stmt = $pdo->prepare("UPDATE `domain` SET `relayhost` = '0' WHERE `relayhost`= :id");
+          $stmt->execute(array(':id' => $id));
+        }
+        catch (PDOException $e) {
+          $_SESSION['return'] = array(
+            'type' => 'danger',
+            'msg' => 'MySQL: '.$e
+          );
+          return false;
+        }
+      }
+      $_SESSION['return'] = array(
+        'type' => 'success',
+        'msg' => sprintf($lang['success']['relayhost_removed'], htmlspecialchars(implode(', ', $hostnames)))
+      );
+    break;
+    case 'get':
+      if ($_SESSION['mailcow_cc_role'] != "admin") {
+        return false;
+      }
+      $relayhosts = array();
+      try {
+        $stmt = $pdo->query("SELECT `id`, `hostname`, `username` FROM `relayhosts`");
+        $relayhosts = $stmt->fetchAll(PDO::FETCH_ASSOC);
+      }
+      catch(PDOException $e) {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => 'MySQL: '.$e
+        );
+      }
+      return $relayhosts;
+    break;
+    case 'details':
+      if ($_SESSION['mailcow_cc_role'] != "admin" || !isset($_data)) {
+        return false;
+      }
+      $relayhostdata = array();
+      try {
+        $stmt = $pdo->prepare("SELECT `id`,
+          `hostname`,
+          `username`,
+          `password`,
+          `active` AS `active_int`,
+          CONCAT(LEFT(`password`, 3), '...') AS `password_short`,
+          CASE `active` WHEN 1 THEN '".$lang['mailbox']['yes']."' ELSE '".$lang['mailbox']['no']."' END AS `active`
+            FROM `relayhosts`
+              WHERE `id` = :id");
+        $stmt->execute(array(':id' => $_data));
+        $relayhostdata = $stmt->fetch(PDO::FETCH_ASSOC);
+
+        if (!empty($relayhostdata)) {
+          $stmt = $pdo->prepare("SELECT GROUP_CONCAT(`domain` SEPARATOR ', ') AS `used_by_domains` FROM `domain` WHERE `relayhost` = :id");
+          $stmt->execute(array(':id' => $_data));
+          $used_by_domains = $stmt->fetch(PDO::FETCH_ASSOC)['used_by_domains'];
+          $used_by_domains = (empty($used_by_domains)) ? '' : $used_by_domains;
+          $relayhostdata['used_by_domains'] = $used_by_domains;
+        }
+      }
+      catch(PDOException $e) {
+        $_SESSION['return'] = array(
+          'type' => 'danger',
+          'msg' => 'MySQL: '.$e
+        );
+      }
+      return $relayhostdata;
+    break;
+  }
+}
\ No newline at end of file
diff --git a/data/web/inc/header.inc.php b/data/web/inc/header.inc.php
index 533711de..98d42ef1 100644
--- a/data/web/inc/header.inc.php
+++ b/data/web/inc/header.inc.php
@@ -29,7 +29,7 @@
 <link rel="shortcut icon" href="/favicon.png" type="image/png">
 <link rel="icon" href="/favicon.png" type="image/png">
 </head>
-<body style="padding-top: 70px;">
+<body style="padding-top: 70px;" id="top">
 <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
   <div class="container-fluid">
     <div class="navbar-header">
diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php
index a429efa8..0940ca18 100644
--- a/data/web/inc/init_db.inc.php
+++ b/data/web/inc/init_db.inc.php
@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
     global $pdo;
 
-    $db_version = "18052017_1017";
+    $db_version = "20072107_1029";
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); 
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -60,6 +60,24 @@ function init_db_schema() {
         ),
         "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
       ),
+      "relayhosts" => array(
+        "cols" => array(
+          "id" => "INT NOT NULL AUTO_INCREMENT",
+          "hostname" => "VARCHAR(255) NOT NULL",
+          "username" => "VARCHAR(255) NOT NULL",
+          "password" => "VARCHAR(255) NOT NULL",
+          "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
+        ),
+        "keys" => array(
+          "primary" => array(
+            "" => array("id")
+          ),
+          "key" => array(
+            "hostname" => array("hostname")
+          )
+        ),
+        "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+      ),
       "alias" => array(
         "cols" => array(
           "address" => "VARCHAR(255) NOT NULL",
@@ -95,7 +113,7 @@ function init_db_schema() {
           "mailboxes" => "INT(10) NOT NULL DEFAULT '0'",
           "maxquota" => "BIGINT(20) NOT NULL DEFAULT '0'",
           "quota" => "BIGINT(20) NOT NULL DEFAULT '102400'",
-          "transport" => "VARCHAR(255) NOT NULL",
+          "relayhost" => "VARCHAR(255) NOT NULL DEFAULT '0'",
           "backupmx" => "TINYINT(1) NOT NULL DEFAULT '0'",
           "relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'",
           "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php
index ebc453f8..e92a91fc 100644
--- a/data/web/inc/prerequisites.inc.php
+++ b/data/web/inc/prerequisites.inc.php
@@ -65,6 +65,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.domain_admin.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.policy.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.dkim.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.fwdhost.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.relayhost.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.fail2ban.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/init_db.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.inc.php';
diff --git a/data/web/js/admin.js b/data/web/js/admin.js
index 371b287f..9676bd5a 100644
--- a/data/web/js/admin.js
+++ b/data/web/js/admin.js
@@ -328,6 +328,44 @@ jQuery(function($){
       }
     });
   }
+  function draw_relayhosts() {
+    ft_forwardinghoststable = FooTable.init('#relayhoststable', {
+      "columns": [
+        {"name":"chkbox","title":"","style":{"maxWidth":"40px","width":"40px"},"filterable": false,"sortable": false,"type":"html"},
+        {"name":"id","type":"text","title":"ID","style":{"width":"50px"}},
+        {"name":"hostname","type":"text","title":lang.host,"style":{"width":"250px"}},
+        {"name":"username","title":lang.username,"breakpoints":"xs sm"},
+        {"name":"used_by_domains","title":lang.in_use_by, "type": "text","breakpoints":"xs sm"},
+        {"name":"active","filterable": false,"style":{"maxWidth":"80px","width":"80px"},"title":lang.active},
+        {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","maxWidth":"180px","width":"180px"},"type":"html","title":lang.action,"breakpoints":"xs sm"}
+      ],
+      "rows": $.ajax({
+        dataType: 'json',
+        url: '/api/v1/get/relayhost/all',
+        jsonp: false,
+        error: function () {
+          console.log('Cannot draw forwarding hosts table');
+        },
+        success: function (data) {
+          $.each(data, function (i, item) {
+            item.action = '<div class="btn-group">' +
+              '<a href="#" id="delete_selected" data-id="single-rlshost" data-api-url="delete/relayhost" data-item="' + encodeURI(item.id) + '" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash"></span> ' + lang.remove + '</a>' +
+              '</div>';
+            item.chkbox = '<input type="checkbox" data-id="rlyhosts" name="multi_select" value="' + item.id + '" />';
+          });
+        }
+      }),
+      "empty": lang.empty,
+      "paging": {
+        "enabled": true,
+        "limit": 5,
+        "size": log_pagination_size
+      },
+      "sorting": {
+        "enabled": true
+      }
+    });
+  }
   function draw_rspamd_history() {
     ft_postfix_logs = FooTable.init('#rspamd_history', {
       "columns": [{
@@ -504,5 +542,20 @@ jQuery(function($){
   draw_fail2ban_logs();
   draw_domain_admins();
   draw_fwd_hosts();
+  draw_relayhosts();
   draw_rspamd_history();
+});
+
+$(window).load(function(){
+  width = $("#scrollbox").width();
+  $(window).bind('scroll', function() {
+    if ($(window).scrollTop() > 70) {
+      $('#scrollbox').addClass('scrollboxFixed');
+      $("#scrollbox").css("width", width);
+    } else {
+      width = $("#scrollbox").width();
+      $('#scrollbox').removeClass('scrollboxFixed');
+      $("#scrollbox").removeAttr("style");
+    }
+  });
 });
\ No newline at end of file
diff --git a/data/web/json_api.php b/data/web/json_api.php
index 56d4e741..71db6806 100644
--- a/data/web/json_api.php
+++ b/data/web/json_api.php
@@ -60,6 +60,39 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u
               ));
             }
           break;
+          case "relayhost":
+            if (isset($_POST['attr'])) {
+              $attr = (array)json_decode($_POST['attr'], true);
+              if (relayhost('add', $attr) === false) {
+                if (isset($_SESSION['return'])) {
+                  echo json_encode($_SESSION['return']);
+                }
+                else {
+                  echo json_encode(array(
+                    'type' => 'error',
+                    'msg' => 'Cannot add item'
+                  ));
+                }
+              }
+              else {
+                if (isset($_SESSION['return'])) {
+                  echo json_encode($_SESSION['return']);
+                }
+                else {
+                  echo json_encode(array(
+                    'type' => 'success',
+                    'msg' => 'Task completed'
+                  ));
+                }
+              }
+            }
+            else {
+              echo json_encode(array(
+                'type' => 'error',
+                'msg' => 'Cannot find attributes in post data'
+              ));
+            }
+          break;
           case "mailbox":
             if (isset($_POST['attr'])) {
               $attr = (array)json_decode($_POST['attr'], true);
@@ -496,6 +529,42 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u
               break;
             }
           break;
+          case "relayhost":
+            switch ($object) {
+              case "all":
+                $relayhosts = relayhost('get');
+                if (!empty($relayhosts)) {
+                  foreach ($relayhosts as $relayhost) {
+                    if ($details = relayhost('details', $relayhost['id'])) {
+                      $data[] = $details;
+                    }
+                    else {
+                      continue;
+                    }
+                  }
+                  if (!isset($data) || empty($data)) {
+                    echo '{}';
+                  }
+                  else {
+                    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+                  }
+                }
+                else {
+                  echo '{}';
+                }
+              break;
+
+              default:
+                $data = relayhost('details', $object);
+                if (!isset($data) || empty($data)) {
+                  echo '{}';
+                }
+                else {
+                  echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+                }
+              break;
+            }
+          break;
           case "logs":
             switch ($object) {
               case "dovecot":
@@ -930,6 +999,47 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u
               ));
             }
           break;
+          case "relayhost":
+            if (isset($_POST['items'])) {
+              $items = (array)json_decode($_POST['items'], true);
+              if (is_array($items)) {
+                if (relayhost('delete', array('id' => $items)) === false) {
+                  if (isset($_SESSION['return'])) {
+                    echo json_encode($_SESSION['return']);
+                  }
+                  else {
+                    echo json_encode(array(
+                      'type' => 'error',
+                      'msg' => 'Deletion of items/s failed'
+                    ));
+                  }
+                }
+                else {
+                  if (isset($_SESSION['return'])) {
+                    echo json_encode($_SESSION['return']);
+                  }
+                  else {
+                    echo json_encode(array(
+                      'type' => 'success',
+                      'msg' => 'Task completed'
+                    ));
+                  }
+                }
+              }
+              else {
+                echo json_encode(array(
+                  'type' => 'error',
+                  'msg' => 'Cannot find id array in post data'
+                ));
+              }
+            }
+            else {
+              echo json_encode(array(
+                'type' => 'error',
+                'msg' => 'Cannot find items in post data'
+              ));
+            }
+          break;
           case "syncjob":
             if (isset($_POST['items'])) {
               $items = (array)json_decode($_POST['items'], true);
@@ -1470,6 +1580,50 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u
               ));
             }
           break;
+          case "relayhost":
+            if (isset($_POST['items']) && isset($_POST['attr'])) {
+              $items = (array)json_decode($_POST['items'], true);
+              $attr = (array)json_decode($_POST['attr'], true);
+              $postarray = array_merge(array('id' => $items), $attr);
+              if (is_array($postarray['id'])) {
+                if (relayhost('edit', $postarray) === false) {
+                  if (isset($_SESSION['return'])) {
+                    echo json_encode($_SESSION['return']);
+                  }
+                  else {
+                    echo json_encode(array(
+                      'type' => 'error',
+                      'msg' => 'Edit failed'
+                    ));
+                  }
+                  exit();
+                }
+                else {
+                  if (isset($_SESSION['return'])) {
+                    echo json_encode($_SESSION['return']);
+                  }
+                  else {
+                    echo json_encode(array(
+                      'type' => 'success',
+                      'msg' => 'Task completed'
+                    ));
+                  }
+                }
+              }
+              else {
+                echo json_encode(array(
+                  'type' => 'error',
+                  'msg' => 'Incomplete post data'
+                ));
+              }
+            }
+            else {
+              echo json_encode(array(
+                'type' => 'error',
+                'msg' => 'Incomplete post data'
+              ));
+            }
+          break;
           case "delimiter_action":
             if (isset($_POST['items']) && isset($_POST['attr'])) {
               $items = (array)json_decode($_POST['items'], true);
diff --git a/data/web/lang/lang.de.php b/data/web/lang/lang.de.php
index eef13b3f..893f3d04 100644
--- a/data/web/lang/lang.de.php
+++ b/data/web/lang/lang.de.php
@@ -485,13 +485,20 @@ $lang['admin']['time'] = 'Zeit';
 $lang['admin']['priority'] = 'Gewichtung';
 $lang['admin']['refresh'] = 'Neu laden';
 $lang['admin']['logs'] = 'Logs';
+$lang['admin']['to_top'] = 'Nach oben';
+$lang['admin']['in_use_by'] = 'Verwendet von';
 $lang['admin']['message'] = 'Nachricht';
 $lang['admin']['forwarding_hosts'] = 'Weiterleitungs-Hosts';
 $lang['admin']['forwarding_hosts_hint'] = 'Eingehende Nachrichten werden von den hier gelisteten Hosts bedingungslos akzeptiert. Diese Hosts werden dann nicht mit DNSBLs abgeglichen oder Greylisting unterworfen. Von ihnen empfangener Spam wird nie abgelehnt, optional kann er aber in den Spam-Ordner einsortiert werden. Die übliche Verwendung für diese Funktion ist, um Mailserver anzugeben, auf denen eine Weiterleitung zu Ihrem mailcow-Server eingerichtet wurde.';
 $lang['admin']['forwarding_hosts_add_hint'] = 'Sie können entweder IPv4/IPv6-Adressen, Netzwerke in CIDR-Notation, Hostnamen (die zu IP-Adressen aufgelöst werden), oder Domainnamen (die zu IP-Adressen aufgelöst werden, indem ihr SPF-Record abgefragt wird oder, in dessen Abwesenheit, ihre MX-Records) angeben.';
+$lang['admin']['relayhosts_hint'] = 'Erstellen Sie Relayhosts, um diese im Einstellungsdialog einer Domain auszuwählen.';
+$lang['admin']['add_relayhost_add_hint'] = 'Bitte beachten Sie, dass Relayhost Anmeldedaten im Klartext gespeichert werden.';
 $lang['admin']['host'] = 'Host';
 $lang['admin']['source'] = 'Quelle';
 $lang['admin']['add_forwarding_host'] = 'Weiterleitungs-Host hinzufügen';
+$lang['admin']['add_relayhost'] = 'Relayhost hinzufügen';
 $lang['delete']['remove_forwardinghost_warning'] = '<b>Warnung:</b> Sie entfernen den Weiterleitungs-Host <b>%s</b>!';
 $lang['success']['forwarding_host_removed'] = "Weiterleitungs-Host %s wurde entfernt";
 $lang['success']['forwarding_host_added'] = "Weiterleitungs-Host %s wurde hinzugefügt";
+$lang['success']['relayhost_removed'] = "Relayhost %s wurde entfernt";
+$lang['success']['relayhost_added'] = "Relayhost %s wurde hinzugefügt";
diff --git a/data/web/lang/lang.en.php b/data/web/lang/lang.en.php
index 3be72fec..f7af1b32 100644
--- a/data/web/lang/lang.en.php
+++ b/data/web/lang/lang.en.php
@@ -498,13 +498,20 @@ $lang['admin']['time'] = 'Time';
 $lang['admin']['priority'] = 'Priority';
 $lang['admin']['message'] = 'Message';
 $lang['admin']['refresh'] = 'Refresh';
+$lang['admin']['to_top'] = 'Back to top';
+$lang['admin']['in_use_by'] = 'In use by';
 $lang['admin']['logs'] = 'Logs';
 $lang['admin']['forwarding_hosts'] = 'Forwarding Hosts';
 $lang['admin']['forwarding_hosts_hint'] = 'Incoming messages are unconditionally accepted from any hosts listed here. These hosts are then not checked against DNSBLs or subjected to greylisting. Spam received from them is never rejected, but optionally it can be filed into the Junk folder. The most common use for this is to specify mail servers on which you have set up a rule that forwards incoming emails to your mailcow server.';
 $lang['admin']['forwarding_hosts_add_hint'] = 'You can either specify IPv4/IPv6 addresses, networks in CIDR notation, host names (which will be resolved to IP addresses), or domain names (which will be resolved to IP addresses by querying SPF records or, in their absence, MX records).';
+$lang['admin']['relayhosts_hint'] = 'Define relayhosts here to be able to select them in a domains configuration dialog.';
+$lang['admin']['add_relayhost_add_hint'] = 'Please be aware that relayhost authentication data will be stored as plain text.';
 $lang['admin']['host'] = 'Host';
 $lang['admin']['source'] = 'Source';
 $lang['admin']['add_forwarding_host'] = 'Add Forwarding Host';
+$lang['admin']['add_relayhost'] = 'Add Relayhost';
 $lang['delete']['remove_forwardinghost_warning'] = '<b>Warning:</b> You are about to remove the forwarding host <b>%s</b>!';
 $lang['success']['forwarding_host_removed'] = "Forwarding host %s has been removed";
 $lang['success']['forwarding_host_added'] = "Forwarding host %s has been added";
+$lang['success']['relayhost_removed'] = "Relayhost %s has been removed";
+$lang['success']['relayhost_added'] = "Relayhost %s has been added";
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 04c81a56..1a38afbd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -201,7 +201,7 @@ services:
             - dovecot
 
     postfix-mailcow:
-      image: mailcow/postfix:1.2
+      image: mailcow/postfix:1.3
       build: ./data/Dockerfiles/postfix
       depends_on:
         unbound-mailcow: