JAMES-3775 ClamAV integration (#1126) master
authorTrần Hồng Quân <55171818+quantranhong1999@users.noreply.github.com>
Fri, 19 Aug 2022 01:41:54 +0000 (08:41 +0700)
committerGitHub <noreply@github.com>
Fri, 19 Aug 2022 01:41:54 +0000 (08:41 +0700)
19 files changed:
third-party/rspamd/README.md
third-party/rspamd/docker-compose-distributed.yml [new file with mode: 0644]
third-party/rspamd/docker-compose.yml [new file with mode: 0644]
third-party/rspamd/sample-configuration/antivirus.conf [new file with mode: 0644]
third-party/rspamd/sample-configuration/mailetcontainer_distributed.xml [new file with mode: 0644]
third-party/rspamd/sample-configuration/mailetcontainer_memory.xml [new file with mode: 0644]
third-party/rspamd/src/main/java/org/apache/james/rspamd/RSpamDScanner.java
third-party/rspamd/src/main/java/org/apache/james/rspamd/client/RSpamDClientConfiguration.java
third-party/rspamd/src/main/java/org/apache/james/rspamd/model/AnalysisResult.java
third-party/rspamd/src/main/java/org/apache/james/rspamd/model/AnalysisResultDeserializer.java [new file with mode: 0644]
third-party/rspamd/src/test/java/org/apache/james/rspamd/DockerClamAV.java [new file with mode: 0644]
third-party/rspamd/src/test/java/org/apache/james/rspamd/DockerRSpamD.java
third-party/rspamd/src/test/java/org/apache/james/rspamd/DockerRSpamDExtensionTest.java
third-party/rspamd/src/test/java/org/apache/james/rspamd/RSpamDScannerTest.java
third-party/rspamd/src/test/java/org/apache/james/rspamd/client/RSpamDHttpClientTest.java
third-party/rspamd/src/test/java/org/apache/james/rspamd/model/AnalysisResultDeserializationTest.java
third-party/rspamd/src/test/resources/mail/attachment/inlineNonVirusTextAttachment.eml [new file with mode: 0644]
third-party/rspamd/src/test/resources/mail/attachment/inlineVirusTextAttachment.eml [new file with mode: 0644]
third-party/rspamd/src/test/resources/rspamd-config/antivirus.conf [new file with mode: 0644]

index 079cb3597f996b7c3ef079b91f0aad08e74337ab..2f98347f0d79ef810a738cddded36dc415312f0c 100644 (file)
@@ -1,6 +1,7 @@
 # James' extensions for RSpamD
 
-This module is for developing and delivering extensions to James for the [RSpamD](https://rspamd.com/) (the spam filtering system) 
+This module is for developing and delivering extensions to James for the [RSpamD](https://rspamd.com/) (the spam filtering system)
+and [ClamAV](https://www.clamav.net/) (the antivirus engine).
 
 ## How to run
 
@@ -24,13 +25,39 @@ guice.extension.task=org.apache.james.rspamd.module.RSpamDTaskExtensionModule
 </listener>
 ```
 
-- Declare the RSpamD mailet for custom mail processing. Mailet pipeline Eg:
-
-```
-<mailet match="All" class="org.apache.james.rspamd.RSpamDScanner"></mailet>
-<mailet match="IsMarkedAsSpam=org.apache.james.rspamd.status" class="WithStorageDirective">
-    <targetFolderName>Spam</targetFolderName>
-</mailet>
+- Declare the RSpamD mailet for custom mail processing. 
+
+  You can specify the `virusProcessor` if you want to enable virus scanning for mail. Upon configurable `virusProcessor`
+you can specify how James process mail virus. We provide a sample Rspamd mailet and `virusProcessor` configuration:
+
+```xml
+<processor state="local-delivery" enableJmx="true">
+    <mailet match="All" class="org.apache.james.rspamd.RSpamDScanner">
+        <rewriteSubject>true</rewriteSubject>
+        <virusProcessor>virus</virusProcessor>
+    </mailet>
+    <mailet match="IsMarkedAsSpam=org.apache.james.rspamd.status" class="WithStorageDirective">
+        <targetFolderName>Spam</targetFolderName>
+    </mailet>
+</processor>
+
+<!--Choose one between these two following virus processor, or configure a custom one if you want-->
+<!--Hard reject virus mail-->
+<processor state="virus" enableJmx="false">
+    <mailet match="All" class="ToRepository">
+        <repositoryPath>file://var/mail/virus/</repositoryPath>
+    </mailet>
+</processor>
+<!--Soft reject virus mail-->
+<processor state="virus" enableJmx="false">
+    <mailet match="All" class="StripAttachment">
+        <remove>all</remove>
+        <pattern>.*</pattern>
+    </mailet>
+    <mailet match="All" class="AddSubjectPrefix">
+        <subjectPrefix>[VIRUS]</subjectPrefix>
+    </mailet>
+</processor>
 ```
 
 - Declare the webadmin for RSpamD in `webadmin.properties`
@@ -40,7 +67,9 @@ extensions.routes=org.apache.james.rspamd.route.FeedMessageRoute
 ```
 How to use admin endpoint, see more at [Additional webadmin endpoints](README.md)
 
-- Docker compose file example: [docker-compose.yml](docker-compose.yml) or [docker-compose-distributed.yml](docker-compose-distributed.yml)
+- Docker compose file example: [docker-compose.yml](docker-compose.yml) or [docker-compose-distributed.yml](docker-compose-distributed.yml).
+  
+  Please configure `ClamAV` integration into `Rspamd` if you want to enable virus scanning.
 - The sample-configuration: [sample-configuration](sample-configuration)
 - For running docker-compose, first compile this project 
 
diff --git a/third-party/rspamd/docker-compose-distributed.yml b/third-party/rspamd/docker-compose-distributed.yml
new file mode 100644 (file)
index 0000000..cf340c5
--- /dev/null
@@ -0,0 +1,82 @@
+version: '3'
+
+services:
+
+  james:
+    depends_on:
+      - elasticsearch
+      - cassandra
+      - tika
+      - rabbitmq
+      - s3
+      - rspamd
+    image: apache/james:distributed-latest
+    container_name: james
+    hostname: james.local
+    volumes:
+      - $PWD/target/apache-james-rspamd-3.8.0-SNAPSHOT-jar-with-dependencies.jar:/root/extensions-jars/james-server-rspamd.jar
+      - $PWD/sample-configuration/keystore:/root/conf/keystore
+      - $PWD/sample-configuration/extensions.properties:/root/conf/extensions.properties
+      - $PWD/sample-configuration/mailetcontainer_distributed.xml:/root/conf/mailetcontainer.xml
+      - $PWD/sample-configuration/listeners.xml:/root/conf/listeners.xml
+      - $PWD/sample-configuration/rspamd.properties:/root/conf/rspamd.properties
+      - $PWD/sample-configuration/webadmin.properties:/root/conf/webadmin.properties
+    ports:
+      - "80:80"
+      - "25:25"
+      - "110:110"
+      - "143:143"
+      - "465:465"
+      - "587:587"
+      - "993:993"
+      - "8000:8000"
+
+  opensearch:
+    image: opensearchproject/opensearch:2.1.0
+    environment:
+      - discovery.type=single-node
+
+  cassandra:
+    image: cassandra:3.11.10
+    ports:
+      - "9042:9042"
+
+  tika:
+    image: apache/tika:1.26
+
+  rabbitmq:
+    image: rabbitmq:3.8.18-management
+    ports:
+      - "5672:5672"
+      - "15672:15672"
+
+  s3:
+    image: zenko/cloudserver:8.2.6
+    container_name: s3.docker.test
+    environment:
+      - SCALITY_ACCESS_KEY_ID=accessKey1
+      - SCALITY_SECRET_ACCESS_KEY=secretKey1
+      - S3BACKEND=mem
+      - LOG_LEVEL=trace
+      - REMOTE_MANAGEMENT_DISABLE=1
+
+  redis:
+    image: redis:6.2.6
+
+  clamav:
+    image: clamav/clamav:0.105
+
+  rspamd:
+    depends_on:
+      - redis
+      - clamav
+    container_name: rspamd
+    image: a16bitsysop/rspamd:3.2-r2-alpine3.16.0-r0
+    environment:
+      - REDIS:redis
+      - CLAMAV:clamav
+      - PASSWORD:admin
+    volumes:
+      - $PWD/sample-configuration/antivirus.conf:/etc/rspamd/override.d/antivirus.conf
+    ports:
+      - 11334:11334
\ No newline at end of file
diff --git a/third-party/rspamd/docker-compose.yml b/third-party/rspamd/docker-compose.yml
new file mode 100644 (file)
index 0000000..0a03b88
--- /dev/null
@@ -0,0 +1,48 @@
+version: '3'
+
+services:
+
+  james:
+    depends_on:
+      - rspamd
+    image: apache/james:memory-latest
+    container_name: james
+    hostname: james.local
+    volumes:
+      - $PWD/target/apache-james-rspamd-3.8.0-SNAPSHOT-jar-with-dependencies.jar:/root/extensions-jars/james-server-rspamd.jar
+      - $PWD/sample-configuration/keystore:/root/conf/keystore
+      - $PWD/sample-configuration/extensions.properties:/root/conf/extensions.properties
+      - $PWD/sample-configuration/mailetcontainer_memory.xml:/root/conf/mailetcontainer.xml
+      - $PWD/sample-configuration/listeners.xml:/root/conf/listeners.xml
+      - $PWD/sample-configuration/rspamd.properties:/root/conf/rspamd.properties
+      - $PWD/sample-configuration/webadmin.properties:/root/conf/webadmin.properties
+    ports:
+      - "80:80"
+      - "25:25"
+      - "110:110"
+      - "143:143"
+      - "465:465"
+      - "587:587"
+      - "993:993"
+      - "8000:8000"
+
+  redis:
+    image: redis:6.2.6
+
+  clamav:
+    image: clamav/clamav:0.105
+
+  rspamd:
+    depends_on:
+      - redis
+      - clamav
+    container_name: rspamd
+    image: a16bitsysop/rspamd:3.2-r2-alpine3.16.0-r0
+    environment:
+      - REDIS:redis
+      - CLAMAV:clamav
+      - PASSWORD:admin
+    volumes:
+      - $PWD/sample-configuration/antivirus.conf:/etc/rspamd/override.d/antivirus.conf
+    ports:
+      - 11334:11334
\ No newline at end of file
diff --git a/third-party/rspamd/sample-configuration/antivirus.conf b/third-party/rspamd/sample-configuration/antivirus.conf
new file mode 100644 (file)
index 0000000..0626fd5
--- /dev/null
@@ -0,0 +1,21 @@
+clamav {
+  # Scan mime_parts seperately - otherwise the complete mail will be transfered to AV Scanner
+  #attachments_only = true; # Before 1.8.1
+  scan_mime_parts = true; # After 1.8.1
+  # Scanning Text is suitable for some av scanner databases (e.g. Sanesecurity)
+  scan_text_mime = true; # 1.8.1 +
+  scan_image_mime = true; # 1.8.1 +
+  # If set force this action if any virus is found (default unset: no action is forced)
+  # action = "reject";
+  message = '${SCANNER}: virus found: "${VIRUS}"';
+  # If set true, log message is emitted for clean messages
+  log_clean = false;
+  # symbol to add (add it to metric if you want non-zero weight)
+  symbol = CLAM_VIRUS;
+  # type of scanner: "clamav", "fprot", "sophos" or "savapi"
+  type = clamav;
+  servers = "clamav:3310";
+  patterns {
+    JUST_EICAR = '^Eicar-Test-Signature$';
+  }
+}
\ No newline at end of file
diff --git a/third-party/rspamd/sample-configuration/mailetcontainer_distributed.xml b/third-party/rspamd/sample-configuration/mailetcontainer_distributed.xml
new file mode 100644 (file)
index 0000000..a18c40e
--- /dev/null
@@ -0,0 +1,174 @@
+<?xml version="1.0"?>
+
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+ -->
+
+<!-- Read https://james.apache.org/server/config-mailetcontainer.html for further details -->
+
+<mailetcontainer enableJmx="true">
+
+    <context>
+        <!-- When the domain part of the postmaster mailAddress is missing, the default domain is appended.
+        You can configure it to (for example) <postmaster>postmaster@myDomain.com</postmaster> -->
+        <postmaster>postmaster</postmaster>
+    </context>
+
+    <spooler>
+        <threads>20</threads>
+        <errorRepository>cassandra://var/mail/error/</errorRepository>
+    </spooler>
+
+    <processors>
+        <processor state="root" enableJmx="true">
+            <mailet match="All" class="PostmasterAlias"/>
+            <mailet match="RelayLimit=30" class="Null"/>
+            <mailet match="All" class="ToProcessor">
+                <processor>transport</processor>
+            </mailet>
+        </processor>
+
+        <processor state="error" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>mailetContainerErrors</metricName>
+            </mailet>
+            <mailet match="All" class="Bounce">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>cassandra://var/mail/error/</repositoryPath>
+                <onMailetException>propagate</onMailetException>
+            </mailet>
+        </processor>
+
+        <processor state="transport" enableJmx="true">
+            <matcher name="relay-allowed" match="org.apache.james.mailetcontainer.impl.matchers.Or">
+                <matcher match="SMTPAuthSuccessful"/>
+                <matcher match="SMTPIsAuthNetwork"/>
+                <matcher match="SentByMailet"/>
+                <matcher match="org.apache.james.jmap.mailet.SentByJmap"/>
+            </matcher>
+
+            <mailet match="All" class="RemoveMimeHeader">
+                <name>bcc</name>
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="RecipientRewriteTable">
+                <errorProcessor>rrt-error</errorProcessor>
+            </mailet>
+            <mailet match="RecipientIsLocal" class="ToProcessor">
+                <processor>local-delivery</processor>
+            </mailet>
+            <mailet match="HostIsLocal" class="ToProcessor">
+                <processor>local-address-error</processor>
+                <notice>550 - Requested action not taken: no such user here</notice>
+            </mailet>
+            <mailet match="relay-allowed" class="ToProcessor">
+                <processor>relay</processor>
+            </mailet>
+            <mailet match="All" class="ToProcessor">
+                <processor>relay-denied</processor>
+            </mailet>
+        </processor>
+
+        <processor state="local-delivery" enableJmx="true">
+            <mailet match="All" class="org.apache.james.rspamd.RSpamDScanner">
+                <rewriteSubject>true</rewriteSubject>
+                <virusProcessor>virus</virusProcessor>
+            </mailet>
+            <mailet match="IsMarkedAsSpam=org.apache.james.rspamd.status" class="WithStorageDirective">
+                <targetFolderName>Spam</targetFolderName>
+            </mailet>
+            <mailet match="All" class="VacationMailet">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="Sieve">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="AddDeliveredToHeader"/>
+            <mailet match="All" class="org.apache.james.jmap.mailet.filter.JMAPFiltering">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="LocalDelivery"/>
+        </processor>
+
+        <processor state="relay" enableJmx="true">
+            <mailet match="All" class="RemoteDelivery">
+                <outgoingQueue>outgoing</outgoingQueue>
+                <delayTime>5000, 100000, 500000</delayTime>
+                <maxRetries>3</maxRetries>
+                <maxDnsProblemRetries>0</maxDnsProblemRetries>
+                <deliveryThreads>10</deliveryThreads>
+                <sendpartial>true</sendpartial>
+                <bounceProcessor>bounces</bounceProcessor>
+            </mailet>
+        </processor>
+
+        <processor state="local-address-error" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>mailetContainerLocalAddressError</metricName>
+            </mailet>
+            <mailet match="All" class="Bounce">
+                <attachment>none</attachment>
+            </mailet>
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>cassandra://var/mail/address-error/</repositoryPath>
+            </mailet>
+        </processor>
+
+        <processor state="relay-denied" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>mailetContainerRelayDenied</metricName>
+            </mailet>
+            <mailet match="All" class="Bounce">
+                <attachment>none</attachment>
+            </mailet>
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>cassandra://var/mail/relay-denied/</repositoryPath>
+                <notice>Warning: You are sending an e-mail to a remote server. You must be authenticated to perform such an operation</notice>
+            </mailet>
+        </processor>
+
+        <processor state="bounces" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>bounces</metricName>
+            </mailet>
+            <mailet match="All" class="DSNBounce">
+                <passThrough>false</passThrough>
+            </mailet>
+        </processor>
+
+        <processor state="rrt-error" enableJmx="false">
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>cassandra://var/mail/rrt-error/</repositoryPath>
+                <passThrough>true</passThrough>
+            </mailet>
+            <mailet match="IsSenderInRRTLoop" class="Null"/>
+            <mailet match="All" class="Bounce"/>
+        </processor>
+
+        <processor state="virus" enableJmx="false">
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>cassandra://var/mail/virus/</repositoryPath>
+            </mailet>
+        </processor>
+
+    </processors>
+
+</mailetcontainer>
+
diff --git a/third-party/rspamd/sample-configuration/mailetcontainer_memory.xml b/third-party/rspamd/sample-configuration/mailetcontainer_memory.xml
new file mode 100644 (file)
index 0000000..782c228
--- /dev/null
@@ -0,0 +1,174 @@
+<?xml version="1.0"?>
+
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+ -->
+
+<!-- Read https://james.apache.org/server/config-mailetcontainer.html for further details -->
+
+<mailetcontainer enableJmx="true">
+
+    <context>
+        <!-- When the domain part of the postmaster mailAddress is missing, the default domain is appended.
+        You can configure it to (for example) <postmaster>postmaster@myDomain.com</postmaster> -->
+        <postmaster>postmaster</postmaster>
+    </context>
+
+    <spooler>
+        <threads>20</threads>
+        <errorRepository>memory://var/mail/error/</errorRepository>
+    </spooler>
+
+    <processors>
+        <processor state="root" enableJmx="true">
+            <mailet match="All" class="PostmasterAlias"/>
+            <mailet match="RelayLimit=30" class="Null"/>
+            <mailet match="All" class="ToProcessor">
+                <processor>transport</processor>
+            </mailet>
+        </processor>
+
+        <processor state="error" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>mailetContainerErrors</metricName>
+            </mailet>
+            <mailet match="All" class="Bounce">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>memory://var/mail/error/</repositoryPath>
+                <onMailetException>propagate</onMailetException>
+            </mailet>
+        </processor>
+
+        <processor state="transport" enableJmx="true">
+            <matcher name="relay-allowed" match="org.apache.james.mailetcontainer.impl.matchers.Or">
+                <matcher match="SMTPAuthSuccessful"/>
+                <matcher match="SMTPIsAuthNetwork"/>
+                <matcher match="SentByMailet"/>
+                <matcher match="org.apache.james.jmap.mailet.SentByJmap"/>
+            </matcher>
+
+            <mailet match="All" class="RemoveMimeHeader">
+                <name>bcc</name>
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="RecipientRewriteTable">
+                <errorProcessor>rrt-error</errorProcessor>
+            </mailet>
+            <mailet match="RecipientIsLocal" class="ToProcessor">
+                <processor>local-delivery</processor>
+            </mailet>
+            <mailet match="HostIsLocal" class="ToProcessor">
+                <processor>local-address-error</processor>
+                <notice>550 - Requested action not taken: no such user here</notice>
+            </mailet>
+            <mailet match="relay-allowed" class="ToProcessor">
+                <processor>relay</processor>
+            </mailet>
+            <mailet match="All" class="ToProcessor">
+                <processor>relay-denied</processor>
+            </mailet>
+        </processor>
+
+        <processor state="local-delivery" enableJmx="true">
+            <mailet match="All" class="org.apache.james.rspamd.RSpamDScanner">
+                <rewriteSubject>true</rewriteSubject>
+                <virusProcessor>virus</virusProcessor>
+            </mailet>
+            <mailet match="IsMarkedAsSpam=org.apache.james.rspamd.status" class="WithStorageDirective">
+                <targetFolderName>Spam</targetFolderName>
+            </mailet>
+            <mailet match="All" class="VacationMailet">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="Sieve">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="AddDeliveredToHeader"/>
+            <mailet match="All" class="org.apache.james.jmap.mailet.filter.JMAPFiltering">
+                <onMailetException>ignore</onMailetException>
+            </mailet>
+            <mailet match="All" class="LocalDelivery"/>
+        </processor>
+
+        <processor state="relay" enableJmx="true">
+            <mailet match="All" class="RemoteDelivery">
+                <outgoingQueue>outgoing</outgoingQueue>
+                <delayTime>5000, 100000, 500000</delayTime>
+                <maxRetries>3</maxRetries>
+                <maxDnsProblemRetries>0</maxDnsProblemRetries>
+                <deliveryThreads>10</deliveryThreads>
+                <sendpartial>true</sendpartial>
+                <bounceProcessor>bounces</bounceProcessor>
+            </mailet>
+        </processor>
+
+        <processor state="local-address-error" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>mailetContainerLocalAddressError</metricName>
+            </mailet>
+            <mailet match="All" class="Bounce">
+                <attachment>none</attachment>
+            </mailet>
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>memory://var/mail/address-error/</repositoryPath>
+            </mailet>
+        </processor>
+
+        <processor state="relay-denied" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>mailetContainerRelayDenied</metricName>
+            </mailet>
+            <mailet match="All" class="Bounce">
+                <attachment>none</attachment>
+            </mailet>
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>memory://var/mail/relay-denied/</repositoryPath>
+                <notice>Warning: You are sending an e-mail to a remote server. You must be authenticated to perform such an operation</notice>
+            </mailet>
+        </processor>
+
+        <processor state="bounces" enableJmx="true">
+            <mailet match="All" class="MetricsMailet">
+                <metricName>bounces</metricName>
+            </mailet>
+            <mailet match="All" class="DSNBounce">
+                <passThrough>false</passThrough>
+            </mailet>
+        </processor>
+
+        <processor state="rrt-error" enableJmx="false">
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>memory://var/mail/rrt-error/</repositoryPath>
+                <passThrough>true</passThrough>
+            </mailet>
+            <mailet match="IsSenderInRRTLoop" class="Null"/>
+            <mailet match="All" class="Bounce"/>
+        </processor>
+
+        <processor state="virus" enableJmx="false">
+            <mailet match="All" class="ToRepository">
+                <repositoryPath>memory://var/mail/virus/</repositoryPath>
+            </mailet>
+        </processor>
+
+    </processors>
+
+</mailetcontainer>
+
index f21c89abd051c3dbfcaafea1a5ad2b9ea642b281..9dd412e202ae2361d0425e77f3c5af8620e77bc8 100644 (file)
@@ -21,6 +21,7 @@ package org.apache.james.rspamd;
 
 
 import java.util.List;
+import java.util.Optional;
 
 import javax.inject.Inject;
 import javax.mail.MessagingException;
@@ -35,6 +36,8 @@ import org.apache.mailet.AttributeValue;
 import org.apache.mailet.Mail;
 import org.apache.mailet.PerRecipientHeaders;
 import org.apache.mailet.base.GenericMailet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.github.fge.lambdas.Throwing;
 import com.google.common.collect.ImmutableList;
@@ -42,9 +45,11 @@ import com.google.common.collect.ImmutableList;
 public class RSpamDScanner extends GenericMailet {
     public static final AttributeName FLAG_MAIL = AttributeName.of("org.apache.james.rspamd.flag");
     public static final AttributeName STATUS_MAIL = AttributeName.of("org.apache.james.rspamd.status");
+    private static final Logger LOGGER = LoggerFactory.getLogger(RSpamDScanner.class);
 
     private final RSpamDHttpClient rSpamDHttpClient;
     private boolean rewriteSubject;
+    private Optional<String> virusProcessor;
 
     @Inject
     public RSpamDScanner(RSpamDHttpClient rSpamDHttpClient) {
@@ -54,6 +59,7 @@ public class RSpamDScanner extends GenericMailet {
     @Override
     public void init() {
         rewriteSubject = getBooleanParameter(getInitParameter("rewriteSubject"), false);
+        virusProcessor = getInitParameterAsOptional("virusProcessor");
     }
 
     @Override
@@ -67,6 +73,13 @@ public class RSpamDScanner extends GenericMailet {
             rSpamDResult.getDesiredRewriteSubject()
                 .ifPresent(Throwing.consumer(desiredRewriteSubject -> mail.getMessage().setSubject(desiredRewriteSubject)));
         }
+
+        if (rSpamDResult.hasVirus()) {
+            virusProcessor.ifPresent(state -> {
+                LOGGER.info("Detected a mail containing virus. Sending mail {} to {}", mail, virusProcessor);
+                mail.setState(state);
+            });
+        }
     }
 
     private void appendRSpamDResultHeader(Mail mail, MailAddress recipient, AnalysisResult rSpamDResult) {
index 2141e0baffcd117c5c1e844bedcba9dd7efae775..83a8a4d86e9e43a2658bd62095f56e1c955d60e6 100644 (file)
@@ -23,7 +23,7 @@ import java.net.URL;
 import java.util.Optional;
 
 public class RSpamDClientConfiguration {
-    public static final Integer DEFAULT_TIMEOUT_IN_SECONDS = 10;
+    public static final Integer DEFAULT_TIMEOUT_IN_SECONDS = 15;
 
     private final URL url;
     private final String password;
index 23fb960a067a10f5cfbfb54a00a1c08e090326d8..2091207bae4265d9172d2c23f05c4db87364b704 100644 (file)
@@ -22,12 +22,11 @@ package org.apache.james.rspamd.model;
 import java.util.Objects;
 import java.util.Optional;
 
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Preconditions;
 
-@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonDeserialize(using = AnalysisResultDeserializer.class)
 public class AnalysisResult {
 
     public static Builder builder() {
@@ -39,6 +38,7 @@ public class AnalysisResult {
         private float score;
         private float requiredScore;
         private Optional<String> desiredRewriteSubject;
+        private boolean hasVirus;
 
         public Builder() {
             desiredRewriteSubject = Optional.empty();
@@ -64,20 +64,25 @@ public class AnalysisResult {
             return this;
         }
 
+        public Builder hasVirus(boolean hasVirus) {
+            this.hasVirus = hasVirus;
+            return this;
+        }
+
         public AnalysisResult build() {
             Preconditions.checkNotNull(action);
 
-            return new AnalysisResult(action, score, requiredScore, desiredRewriteSubject);
+            return new AnalysisResult(action, score, requiredScore, desiredRewriteSubject, hasVirus);
         }
     }
 
     public enum Action {
-        @JsonProperty("no action") NO_ACTION("no action"), // message is likely ham
-        @JsonProperty("greylist") GREY_LIST("greylist"), // message should be grey listed
-        @JsonProperty("add header") ADD_HEADER("add header"), // message is suspicious and should be marked as spam
-        @JsonProperty("rewrite subject") REWRITE_SUBJECT("rewrite subject"), // message is suspicious and should have subject rewritten
-        @JsonProperty("soft reject") SOFT_REJECT("soft reject"), // message should be temporary rejected (for example, due to rate limit exhausting)
-        @JsonProperty("reject") REJECT("reject"); // message should be rejected as spam
+        NO_ACTION("no action"), // message is likely ham
+        GREY_LIST("greylist"), // message should be grey listed
+        ADD_HEADER("add header"), // message is suspicious and should be marked as spam
+        REWRITE_SUBJECT("rewrite subject"), // message is suspicious and should have subject rewritten
+        SOFT_REJECT("soft reject"), // message should be temporary rejected (for example, due to rate limit exhausting)
+        REJECT("reject"); // message should be rejected as spam
 
         private final String description;
 
@@ -94,15 +99,14 @@ public class AnalysisResult {
     private final float score;
     private final float requiredScore;
     private final Optional<String> desiredRewriteSubject;
+    private final boolean hasVirus;
 
-    public AnalysisResult(@JsonProperty("action") Action action,
-                          @JsonProperty("score") float score,
-                          @JsonProperty("required_score") float requiredScore,
-                          @JsonProperty("subject") Optional<String> desiredRewriteSubject) {
+    public AnalysisResult(Action action, float score, float requiredScore, Optional<String> desiredRewriteSubject, boolean hasVirus) {
         this.action = action;
         this.score = score;
         this.requiredScore = requiredScore;
         this.desiredRewriteSubject = desiredRewriteSubject;
+        this.hasVirus = hasVirus;
     }
 
     public Action getAction() {
@@ -121,6 +125,10 @@ public class AnalysisResult {
         return desiredRewriteSubject;
     }
 
+    public boolean hasVirus() {
+        return hasVirus;
+    }
+
     @Override
     public final boolean equals(Object o) {
         if (o instanceof AnalysisResult) {
@@ -129,14 +137,15 @@ public class AnalysisResult {
             return Objects.equals(this.score, that.score)
                 && Objects.equals(this.requiredScore, that.requiredScore)
                 && Objects.equals(this.action, that.action)
-                && Objects.equals(this.desiredRewriteSubject, that.desiredRewriteSubject);
+                && Objects.equals(this.desiredRewriteSubject, that.desiredRewriteSubject)
+                && Objects.equals(this.hasVirus, that.hasVirus);
         }
         return false;
     }
 
     @Override
     public final int hashCode() {
-        return Objects.hash(action, score, requiredScore, desiredRewriteSubject);
+        return Objects.hash(action, score, requiredScore, desiredRewriteSubject, hasVirus);
     }
 
     @Override
@@ -146,6 +155,7 @@ public class AnalysisResult {
             .add("score", score)
             .add("requiredScore", requiredScore)
             .add("desiredRewriteSubject", desiredRewriteSubject)
+            .add("hasVirus", hasVirus)
             .toString();
     }
 }
diff --git a/third-party/rspamd/src/main/java/org/apache/james/rspamd/model/AnalysisResultDeserializer.java b/third-party/rspamd/src/main/java/org/apache/james/rspamd/model/AnalysisResultDeserializer.java
new file mode 100644 (file)
index 0000000..25b9928
--- /dev/null
@@ -0,0 +1,74 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.rspamd.model;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+public class AnalysisResultDeserializer extends StdDeserializer<AnalysisResult> {
+
+    public AnalysisResultDeserializer() {
+        this(null);
+    }
+
+    protected AnalysisResultDeserializer(Class<?> vc) {
+        super(vc);
+    }
+
+    @Override
+    public AnalysisResult deserialize(JsonParser jp, DeserializationContext deserializationContext) throws IOException {
+        JsonNode node = jp.getCodec().readTree(jp);
+        AnalysisResult.Action action = deserializeAction(node.get("action").asText());
+        float score = node.get("score").floatValue();
+        float requiredScore = node.get("required_score").floatValue();
+        Optional<String> desiredRewriteSubject = deserializeRewriteSubject(node);
+        boolean hasVirus = deserializeClamVirus(node);
+
+        return new AnalysisResult(action,score, requiredScore, desiredRewriteSubject, hasVirus);
+    }
+
+    private AnalysisResult.Action deserializeAction(String actionAsString) {
+        for (AnalysisResult.Action action : AnalysisResult.Action.values()) {
+            if (action.getDescription().equals(actionAsString)) {
+                return action;
+            }
+        }
+        throw new RuntimeException("There is no match deserialized action.");
+    }
+
+    private Optional<String> deserializeRewriteSubject(JsonNode node) {
+        JsonNode rewriteSubjectJsonNode = node.get("subject");
+        if (rewriteSubjectJsonNode == null) {
+            return Optional.empty();
+        }
+        return Optional.of(rewriteSubjectJsonNode.asText());
+    }
+
+    private boolean deserializeClamVirus(JsonNode node) {
+        JsonNode clamVirusJsonNode = node.get("symbols").get("CLAM_VIRUS");
+        return clamVirusJsonNode != null;
+    }
+
+}
diff --git a/third-party/rspamd/src/test/java/org/apache/james/rspamd/DockerClamAV.java b/third-party/rspamd/src/test/java/org/apache/james/rspamd/DockerClamAV.java
new file mode 100644 (file)
index 0000000..7fdf946
--- /dev/null
@@ -0,0 +1,53 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.rspamd;
+
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.Network;
+import org.testcontainers.utility.DockerImageName;
+
+public class DockerClamAV {
+    private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("clamav/clamav");
+    private static final String DEFAULT_TAG = "0.105";
+    private static final int DEFAULT_PORT = 3310;
+
+    private final GenericContainer<?> container;
+
+    public DockerClamAV(Network network) {
+        this.container = new GenericContainer<>(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG))
+            .withExposedPorts(DEFAULT_PORT)
+            .withNetwork(network)
+            .withNetworkAliases("clamav");
+    }
+
+    public Integer getPort() {
+        return container.getMappedPort(DEFAULT_PORT);
+    }
+
+    public void start() {
+        if (!container.isRunning()) {
+            container.start();
+        }
+    }
+
+    public void stop() {
+        container.stop();
+    }
+}
index 5f7d95a19145fa12583364f09447c6a03f20f7e0..864eaf0bc8057e77664268e033e99fdca3be4348 100644 (file)
@@ -32,12 +32,14 @@ public class DockerRSpamD {
     private static final int DEFAULT_PORT = 11334;
 
     private final DockerRedis dockerRedis;
+    private final DockerClamAV dockerClamAV;
     private final GenericContainer<?> container;
     private final Network network;
 
     public DockerRSpamD() {
         this.network = Network.newNetwork();
         this.dockerRedis = new DockerRedis(network);
+        this.dockerClamAV = new DockerClamAV(network);
         this.container = createRspamD();
     }
 
@@ -45,7 +47,9 @@ public class DockerRSpamD {
         return new GenericContainer<>(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG))
             .withExposedPorts(DEFAULT_PORT)
             .withEnv("REDIS", "redis")
+            .withEnv("CLAMAV", "clamav")
             .withEnv("PASSWORD", PASSWORD)
+            .withCopyFileToContainer(MountableFile.forClasspathResource("rspamd-config/antivirus.conf"), "/etc/rspamd/override.d/")
             .withCopyFileToContainer(MountableFile.forClasspathResource("rspamd-config/actions.conf"), "/etc/rspamd/")
             .withNetwork(network);
     }
@@ -55,6 +59,7 @@ public class DockerRSpamD {
     }
 
     public void start() {
+        dockerClamAV.start();
         dockerRedis.start();
         if (!container.isRunning()) {
             container.start();
@@ -64,6 +69,7 @@ public class DockerRSpamD {
     public void stop() {
         container.stop();
         dockerRedis.stop();
+        dockerClamAV.stop();
     }
 
     public void flushAll() {
index 56cdd9d6fe6015196de9d362c7f853fc2da6b365..538f04d3a318cae005c6219b6fcd10b233325ecd 100644 (file)
@@ -27,6 +27,8 @@ import org.apache.james.junit.categories.Unstable;
 import org.apache.james.util.Port;
 import org.apache.james.webadmin.WebAdminUtils;
 import org.eclipse.jetty.http.HttpStatus;
+import org.hamcrest.core.IsIterableContaining;
+import org.hamcrest.core.IsNull;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
@@ -121,4 +123,31 @@ public class DockerRSpamDExtensionTest {
             .statusCode(HttpStatus.FORBIDDEN_403)
             .body("error", is("Unauthorized"));
     }
+
+    @Test
+    void checkVirusEmailWithExactPasswordHeaderShouldReturnClamVirusSymbol() {
+        RequestSpecification rspamdApi = WebAdminUtils.spec(Port.of(rSpamDExtension.dockerRSpamD().getPort()));
+
+        rspamdApi
+            .header(new Header("Password", PASSWORD))
+            .body(ClassLoader.getSystemResourceAsStream("mail/attachment/inlineVirusTextAttachment.eml"))
+            .post("checkv2")
+        .then()
+            .statusCode(HttpStatus.OK_200)
+            .body("symbols.CLAM_VIRUS.name", is("CLAM_VIRUS"))
+            .body("symbols.CLAM_VIRUS.options", IsIterableContaining.hasItem("Eicar-Signature"));
+    }
+
+    @Test
+    void checkNonVirusEmailWithExactPasswordHeaderShouldNotReturnClamVirusSymbol() {
+        RequestSpecification rspamdApi = WebAdminUtils.spec(Port.of(rSpamDExtension.dockerRSpamD().getPort()));
+
+        rspamdApi
+            .header(new Header("Password", PASSWORD))
+            .body(ClassLoader.getSystemResourceAsStream("mail/attachment/inlineNonVirusTextAttachment.eml"))
+            .post("checkv2")
+        .then()
+            .statusCode(HttpStatus.OK_200)
+            .body("symbols.CLAM_VIRUS", IsNull.nullValue());
+    }
 }
index f5ae3bbfc40aa77d653d4cd571ce4778c0720859..9a6ec889dec029188e5a914ded718af335bc4735 100644 (file)
@@ -252,4 +252,64 @@ class RSpamDScannerTest {
 
         assertThat(mail.getMessage().getSubject()).isEqualTo(INIT_SUBJECT);
     }
+
+    @Test
+    void shouldSendMailToSpamProcessorWhenMailHasAVirusAndConfigVirusProcessor() throws Exception {
+        FakeMailetConfig mailetConfig = FakeMailetConfig.builder()
+            .setProperty("virusProcessor", "virus")
+            .build();
+
+        MimeMessage mimeMessage = MimeMessageUtil.mimeMessageFromStream(
+            ClassLoader.getSystemResourceAsStream("mail/attachment/inlineVirusTextAttachment.eml"));
+
+        Mail mail = FakeMail.builder()
+            .name("name")
+            .recipient("user1@exemple.com")
+            .mimeMessage(mimeMessage)
+            .build();
+
+        mailet.init(mailetConfig);
+        mailet.service(mail);
+
+        assertThat(mail.getState()).isEqualTo("virus");
+    }
+
+    @Test
+    void shouldNotSendMailToVirusProcessorWhenMailHasNoVirus() throws Exception {
+        FakeMailetConfig mailetConfig = FakeMailetConfig.builder()
+            .setProperty("virusProcessor", "virus")
+            .build();
+
+        MimeMessage mimeMessage = MimeMessageUtil.mimeMessageFromStream(
+            ClassLoader.getSystemResourceAsStream("mail/attachment/inlineNonVirusTextAttachment.eml"));
+
+        Mail mail = FakeMail.builder()
+            .name("name")
+            .recipient("user1@exemple.com")
+            .mimeMessage(mimeMessage)
+            .build();
+
+        mailet.init(mailetConfig);
+        mailet.service(mail);
+
+        assertThat(mail.getState()).isNull();
+    }
+
+    @Test
+    void shouldNotSendMailToVirusProcessorWhenMailHasAVirusByDefault() throws Exception {
+        FakeMailetConfig mailetConfig = FakeMailetConfig.builder().build();
+        MimeMessage mimeMessage = MimeMessageUtil.mimeMessageFromStream(
+            ClassLoader.getSystemResourceAsStream("mail/attachment/inlineVirusTextAttachment.eml"));
+
+        Mail mail = FakeMail.builder()
+            .name("name")
+            .recipient("user1@exemple.com")
+            .mimeMessage(mimeMessage)
+            .build();
+
+        mailet.init(mailetConfig);
+        mailet.service(mail);
+
+        assertThat(mail.getState()).isNull();
+    }
 }
\ No newline at end of file
index 16f41224c8ee823c1974efa5634a930ecbb46f13..7f2626f013e16ac36844a29bed82a95dc8dcc021 100644 (file)
@@ -51,17 +51,23 @@ import io.restassured.specification.RequestSpecification;
 class RSpamDHttpClientTest {
     private final static String SPAM_MESSAGE_PATH = "mail/spam/spam8.eml";
     private final static String HAM_MESSAGE_PATH = "mail/ham/ham1.eml";
+    private final static String VIRUS_MESSAGE_PATH = "mail/attachment/inlineVirusTextAttachment.eml";
+    private final static String NON_VIRUS_MESSAGE_PATH = "mail/attachment/inlineNonVirusTextAttachment.eml";
 
     @RegisterExtension
     static DockerRSpamDExtension rSpamDExtension = new DockerRSpamDExtension();
 
     private byte[] spamMessage;
     private byte[] hamMessage;
+    private byte[] virusMessage;
+    private byte[] nonVirusMessage;
 
     @BeforeEach
     void setup() {
         spamMessage = ClassLoaderUtils.getSystemResourceAsByteArray(SPAM_MESSAGE_PATH);
         hamMessage = ClassLoaderUtils.getSystemResourceAsByteArray(HAM_MESSAGE_PATH);
+        virusMessage = ClassLoaderUtils.getSystemResourceAsByteArray(VIRUS_MESSAGE_PATH);
+        nonVirusMessage = ClassLoaderUtils.getSystemResourceAsByteArray(NON_VIRUS_MESSAGE_PATH);
     }
 
     @Test
@@ -124,6 +130,7 @@ class RSpamDHttpClientTest {
             softly.assertThat(analysisResult.getAction()).isEqualTo(AnalysisResult.Action.NO_ACTION);
             softly.assertThat(analysisResult.getRequiredScore()).isEqualTo(14.0F);
             softly.assertThat(analysisResult.getDesiredRewriteSubject()).isEqualTo(Optional.empty());
+            softly.assertThat(analysisResult.hasVirus()).isEqualTo(false);
         });
 
         RequestSpecification rspamdApi = WebAdminUtils.spec(Port.of(rSpamDExtension.dockerRSpamD().getPort()));
@@ -156,6 +163,24 @@ class RSpamDHttpClientTest {
             .doesNotThrowAnyException();
     }
 
+    @Test
+    void checkVirusMailUsingRSpamDClientWithExactPasswordShouldReturnHasVirus() {
+        RSpamDClientConfiguration configuration = new RSpamDClientConfiguration(rSpamDExtension.getBaseUrl(), PASSWORD, Optional.empty());
+        RSpamDHttpClient client = new RSpamDHttpClient(configuration);
+
+        AnalysisResult analysisResult = client.checkV2(new ByteArrayInputStream(virusMessage)).block();
+        assertThat(analysisResult.hasVirus()).isTrue();
+    }
+
+    @Test
+    void checkNonVirusMailUsingRSpamDClientWithExactPasswordShouldReturnHasNoVirus() {
+        RSpamDClientConfiguration configuration = new RSpamDClientConfiguration(rSpamDExtension.getBaseUrl(), PASSWORD, Optional.empty());
+        RSpamDHttpClient client = new RSpamDHttpClient(configuration);
+
+        AnalysisResult analysisResult = client.checkV2(new ByteArrayInputStream(nonVirusMessage)).block();
+        assertThat(analysisResult.hasVirus()).isFalse();
+    }
+
     private void reportAsSpam(RSpamDHttpClient client, InputStream inputStream) {
         client.reportAsSpam(inputStream).block();
     }
index 4e8067398b4141243bee177b5acb227276dfd45c..52f3abc7f05245e2e38c98f02f8b3c60197cd5d4 100644 (file)
@@ -58,6 +58,7 @@ public class AnalysisResultDeserializationTest {
             .action(AnalysisResult.Action.ADD_HEADER)
             .score(5.2F)
             .requiredScore(7.0F)
+            .hasVirus(false)
             .build());
     }
 
@@ -93,6 +94,140 @@ public class AnalysisResultDeserializationTest {
             .score(5.2F)
             .requiredScore(7.0F)
             .desiredRewriteSubject("A rewritten ham subject")
+            .hasVirus(false)
+            .build());
+    }
+
+    @Test
+    void shouldBeDeserializedWellWhenHasClamVirusSymbol() throws JsonProcessingException {
+        String json = "{\n" +
+            "  \"is_skipped\": false,\n" +
+            "  \"score\": 3.500000,\n" +
+            "  \"required_score\": 14.0,\n" +
+            "  \"action\": \"no action\",\n" +
+            "  \"symbols\": {\n" +
+            "    \"ARC_NA\": {\n" +
+            "      \"name\": \"ARC_NA\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"ARC signature absent\"\n" +
+            "    },\n" +
+            "    \"FROM_HAS_DN\": {\n" +
+            "      \"name\": \"FROM_HAS_DN\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"From header has a display name\"\n" +
+            "    },\n" +
+            "    \"MIME_GOOD\": {\n" +
+            "      \"name\": \"MIME_GOOD\",\n" +
+            "      \"score\": -0.100000,\n" +
+            "      \"metric_score\": -0.100000,\n" +
+            "      \"description\": \"Known content-type\",\n" +
+            "      \"options\": [\n" +
+            "        \"multipart/mixed\",\n" +
+            "        \"text/plain\"\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"TO_DN_NONE\": {\n" +
+            "      \"name\": \"TO_DN_NONE\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"None of the recipients have display names\"\n" +
+            "    },\n" +
+            "    \"RCPT_COUNT_ONE\": {\n" +
+            "      \"name\": \"RCPT_COUNT_ONE\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"One recipient\",\n" +
+            "      \"options\": [\n" +
+            "        \"1\"\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"CLAM_VIRUS\": {\n" +
+            "      \"name\": \"CLAM_VIRUS\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"options\": [\n" +
+            "        \"Eicar-Signature\"\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"RCVD_COUNT_ZERO\": {\n" +
+            "      \"name\": \"RCVD_COUNT_ZERO\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"Message has no Received headers\",\n" +
+            "      \"options\": [\n" +
+            "        \"0\"\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"TO_EQ_FROM\": {\n" +
+            "      \"name\": \"TO_EQ_FROM\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"To address matches the From address\"\n" +
+            "    },\n" +
+            "    \"R_DKIM_NA\": {\n" +
+            "      \"name\": \"R_DKIM_NA\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"description\": \"Missing DKIM signature\"\n" +
+            "    },\n" +
+            "    \"DATE_IN_PAST\": {\n" +
+            "      \"name\": \"DATE_IN_PAST\",\n" +
+            "      \"score\": 1.0,\n" +
+            "      \"metric_score\": 1.0,\n" +
+            "      \"description\": \"Message date is in past\",\n" +
+            "      \"options\": [\n" +
+            "        \"51163\"\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"MIME_TRACE\": {\n" +
+            "      \"name\": \"MIME_TRACE\",\n" +
+            "      \"score\": 0.0,\n" +
+            "      \"metric_score\": 0.0,\n" +
+            "      \"options\": [\n" +
+            "        \"0:+\",\n" +
+            "        \"1:+\",\n" +
+            "        \"2:+\"\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"HFILTER_HOSTNAME_UNKNOWN\": {\n" +
+            "      \"name\": \"HFILTER_HOSTNAME_UNKNOWN\",\n" +
+            "      \"score\": 2.500000,\n" +
+            "      \"metric_score\": 2.500000,\n" +
+            "      \"description\": \"Unknown client hostname (PTR or FCrDNS verification failed)\"\n" +
+            "    },\n" +
+            "    \"DMARC_POLICY_SOFTFAIL\": {\n" +
+            "      \"name\": \"DMARC_POLICY_SOFTFAIL\",\n" +
+            "      \"score\": 0.100000,\n" +
+            "      \"metric_score\": 0.100000,\n" +
+            "      \"description\": \"DMARC failed\",\n" +
+            "      \"options\": [\n" +
+            "        \"linagora.com : No valid SPF, No valid DKIM\",\n" +
+            "        \"none\"\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  },\n" +
+            "  \"messages\": {\n" +
+            "\n" +
+            "  },\n" +
+            "  \"message-id\": \"befd8cab-9c9c-5537-4e77-937f32326087@any.com\",\n" +
+            "  \"time_real\": 0.381197,\n" +
+            "  \"milter\": {\n" +
+            "    \"remove_headers\": {\n" +
+            "      \"X-Spam\": 0\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+
+        ObjectMapper objectMapper = new ObjectMapper().registerModule(new Jdk8Module());
+        AnalysisResult analysisResult = objectMapper.readValue(json, AnalysisResult.class);
+
+        assertThat(analysisResult).isEqualTo(AnalysisResult.builder()
+            .action(AnalysisResult.Action.NO_ACTION)
+            .score(3.5F)
+            .requiredScore(14.0F)
+            .hasVirus(true)
             .build());
     }
 }
diff --git a/third-party/rspamd/src/test/resources/mail/attachment/inlineNonVirusTextAttachment.eml b/third-party/rspamd/src/test/resources/mail/attachment/inlineNonVirusTextAttachment.eml
new file mode 100644 (file)
index 0000000..b2bf151
--- /dev/null
@@ -0,0 +1,24 @@
+To: me@linagora.com
+From: Benoit Tellier <me@linagora.com>
+Subject: Mail with text attachment
+Message-ID: <befd8cab-9c9c-5537-4e77-937f32326087@any.com>
+Date: Thu, 13 Oct 2016 10:26:20 +0200
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------4FD2D252DB453546C22C25B2"
+
+This is a multi-part message in MIME format.
+--------------4FD2D252DB453546C22C25B2
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: 7bit
+
+I'm the body!
+
+--------------4FD2D252DB453546C22C25B2
+Content-Disposition: inline
+Content-Type: text/plain; charset=UTF-8;
+ name="attachment.txt"
+Content-ID: <id>
+
+Non virus text
+--------------4FD2D252DB453546C22C25B2--
diff --git a/third-party/rspamd/src/test/resources/mail/attachment/inlineVirusTextAttachment.eml b/third-party/rspamd/src/test/resources/mail/attachment/inlineVirusTextAttachment.eml
new file mode 100644 (file)
index 0000000..32cb41c
--- /dev/null
@@ -0,0 +1,24 @@
+To: me@linagora.com
+From: Benoit Tellier <me@linagora.com>
+Subject: Mail with text attachment
+Message-ID: <befd8cab-9c9c-5537-4e77-937f32326087@any.com>
+Date: Thu, 13 Oct 2016 10:26:20 +0200
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="------------4FD2D252DB453546C22C25B2"
+
+This is a multi-part message in MIME format.
+--------------4FD2D252DB453546C22C25B2
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: 7bit
+
+I'm the body!
+
+--------------4FD2D252DB453546C22C25B2
+Content-Disposition: inline
+Content-Type: text/plain; charset=UTF-8;
+ name="attachment.txt"
+Content-ID: <id>
+
+X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
+--------------4FD2D252DB453546C22C25B2--
diff --git a/third-party/rspamd/src/test/resources/rspamd-config/antivirus.conf b/third-party/rspamd/src/test/resources/rspamd-config/antivirus.conf
new file mode 100644 (file)
index 0000000..bcff145
--- /dev/null
@@ -0,0 +1,21 @@
+clamav {
+  # Scan mime_parts seperately - otherwise the complete mail will be transfered to AV Scanner
+  #attachments_only = true; # Before 1.8.1
+  scan_mime_parts = true; # After 1.8.1
+  # Scanning Text is suitable for some av scanner databases (e.g. Sanesecurity)
+  scan_text_mime = true; # 1.8.1 +
+  scan_image_mime = true; # 1.8.1 +
+  # If set force this action if any virus is found (default unset: no action is forced)
+#   action = "reject";
+  message = '${SCANNER}: virus found: "${VIRUS}"';
+  # If set true, log message is emitted for clean messages
+  log_clean = false;
+  # symbol to add (add it to metric if you want non-zero weight)
+  symbol = CLAM_VIRUS;
+  # type of scanner: "clamav", "fprot", "sophos" or "savapi"
+  type = clamav;
+  servers = "clamav:3310";
+  patterns {
+    JUST_EICAR = '^Eicar-Test-Signature$';
+  }
+}
\ No newline at end of file