diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index edbc9719340957a185bba1acb4476ee79860d612..9c45ac80f264d628c9733a1c882f6321947772fd 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -12,6 +12,28 @@ variables:
 services:
   - docker:dind
 
+before_script:
+  - mkdir -p $HOME/.docker
+  - |
+    cat << EOF > $HOME/.docker/config.json
+    {
+      "auths": {
+        "docker.ozg-sh.de": {
+          "username": "$NEXUS_USER",
+          "password": "$NEXUS_PASSWORD"
+        }
+      },
+      "proxies": {
+        "default": {
+          "httpProxy": "$HTTP_PROXY",
+          "httpsProxy": "$HTTPS_PROXY",
+          "noProxy": "$NO_PROXY"
+        }
+      }
+    }
+    EOF
+  - cat ~/.docker/config.json
+
 cache:
   paths:
     - .m2/repository/
@@ -22,6 +44,11 @@ stages:
   - test
   - publish
 
+.get-version:
+  before_script:
+    - export PROJECT_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout $MAVEN_CLI_OPTS | cut -d'-' -f1)
+    - export PROJECT_ARTIFACTID=$(mvn help:evaluate -Dexpression=project.artifactId -q -DforceStdout $MAVEN_CLI_OPTS)
+
 build:
   stage: build
   script:
@@ -54,6 +81,17 @@ snapshot-nexus:
   only:
     - main
 
+push-merge-request-snapshot-nexus:
+  stage: publish
+  before_script:
+    - !reference [ .get-version, before_script ]
+  script:
+    - mvn versions:set -DnewVersion=${PROJECT_VERSION}-MR-${CI_MERGE_REQUEST_IID}-SNAPSHOT $MAVEN_CLI_OPTS
+    - mvn deploy -Pnexus-deploy $MAVEN_DEPLOY_CLI_OPTS $MAVEN_CLI_OPTS
+  rules:
+    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
+      when: manual
+
 release-gitlab:
   stage: publish
   script:
diff --git a/lombok.config b/lombok.config
index 92efe13b61eab60013e5ebc2c50e89792775baa2..1d1c35e4338bf63f0abbe65b582b425da916151f 100644
--- a/lombok.config
+++ b/lombok.config
@@ -4,4 +4,6 @@ lombok.log.log4j.flagUsage = ERROR
 lombok.data.flagUsage = ERROR
 lombok.nonNull.exceptionType = IllegalArgumentException
 lombok.nonNull.flagUsage = ERROR
-lombok.addLombokGeneratedAnnotation = true
\ No newline at end of file
+lombok.addLombokGeneratedAnnotation = true
+lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
+lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index d7fdd2a3f9f550d3ca4c7fa093eccb2d53c6df03..0daea7fe22ad13a074facfae4a3d9bbefdc0c646 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,9 +18,10 @@
 
 	<properties>
 		<api-lib.version>0.16.0</api-lib.version>
-		<nachrichten-manager.version>2.17.0-SNAPSHOT</nachrichten-manager.version>
-		<openapi-generator.version>7.10.0</openapi-generator.version>
+		<nachrichten-manager-postfach-interface.version>2.18.0-OZG-4097-OSI2-Anbindung-SNAPSHOT</nachrichten-manager-postfach-interface.version>
+		<openapi-generator.version>7.11.0</openapi-generator.version>
 		<swagger-parser.version>2.1.23</swagger-parser.version>
+		<wiremock.version>3.12.0</wiremock.version>
 		<wiremock-spring-boot.version>3.6.0</wiremock-spring-boot.version>
 	</properties>
 	<dependencies>
@@ -33,17 +34,28 @@
 		<dependency>
 			<groupId>de.ozgcloud.nachrichten</groupId>
 			<artifactId>nachrichten-manager-postfach-interface</artifactId>
-			<version>${nachrichten-manager.version}</version>
+			<version>${nachrichten-manager-postfach-interface.version}</version>
 		</dependency>
-
 		<dependency>
 			<groupId>org.springframework.boot</groupId>
 			<artifactId>spring-boot-starter-validation</artifactId>
 		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-data-mongodb</artifactId>
+		</dependency>
 		<dependency>
 			<groupId>org.springframework.boot</groupId>
 			<artifactId>spring-boot-starter-oauth2-client</artifactId>
 		</dependency>
+		<dependency>
+			<groupId>net.devh</groupId>
+			<artifactId>grpc-server-spring-boot-starter</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>net.devh</groupId>
+			<artifactId>grpc-client-spring-boot-starter</artifactId>
+		</dependency>
 
 		<dependency>
 			<groupId>org.mapstruct</groupId>
@@ -77,6 +89,7 @@
 			<scope>test</scope>
 		</dependency>
 
+
 		<!-- commons -->
 		<dependency>
 			<groupId>org.apache.commons</groupId>
@@ -139,7 +152,10 @@
 						<configuration>
 							<inputSpec>${project.basedir}/spec/postfach-api-facade.yaml</inputSpec>
 							<generatorName>java</generatorName>
+							<generateModelTests>false</generateModelTests>
+							<generateApiTests>false</generateApiTests>
 							<configOptions>
+								<!-- https://openapi-generator.tech/docs/generators/java/#config-options -->
 								<sourceFolder>src/gen/java/main</sourceFolder>
 								<serializationLibrary>jackson</serializationLibrary>
 								<library>restclient</library>
@@ -147,6 +163,8 @@
 								<apiPackage>de.ozgcloud.nachrichten.postfach.osiv2.gen.api</apiPackage>
 								<modelPackage>de.ozgcloud.nachrichten.postfach.osiv2.gen.model</modelPackage>
 								<openApiNullable>false</openApiNullable>
+								<generateBuilders>true</generateBuilders>
+								<useAbstractionForFiles>true</useAbstractionForFiles>
 							</configOptions>
 						</configuration>
 					</execution>
diff --git a/scripts/tag-and-push-vorgang-manager-image.sh b/scripts/tag-and-push-vorgang-manager-image.sh
index a9358632df041d70d4331cd3b0bdf4a21afacf75..b530349287ad861e3e65eb8270c8079f2e6c87f6 100755
--- a/scripts/tag-and-push-vorgang-manager-image.sh
+++ b/scripts/tag-and-push-vorgang-manager-image.sh
@@ -2,7 +2,7 @@
 
 set -e
 
-VERSION=2.22.0-OZG-4094-SNAPSHOT-9
+VERSION=2.23.0-OZG-4097-SNAPSHOT-12
 
 docker tag docker.ozg-sh.de/vorgang-manager:build-latest docker.ozg-sh.de/vorgang-manager:$VERSION
 docker push docker.ozg-sh.de/vorgang-manager:$VERSION
\ No newline at end of file
diff --git a/spec/postfach-api-facade.yaml b/spec/postfach-api-facade.yaml
index 0414d95e73a7a704396be50ed764b23684c96efe..9a67d1cbd7ec9a71af0f96dfbc0512d6a5abd6aa 100644
--- a/spec/postfach-api-facade.yaml
+++ b/spec/postfach-api-facade.yaml
@@ -844,7 +844,7 @@ paths:
                 detail: Der Dienst ist zurzeit nicht verfügbar.
   /MessageExchange/v1/Send/{mailboxId}:
     post:
-      operationId: SendMessage
+      operationId: sendMessage
       tags:
       - MessageExchange
       summary: Sendet eine Nachricht an ein externes oder internes Postfach.
@@ -1733,6 +1733,7 @@ paths:
           description: OK
   /Quarantine/v1/Upload/{guid}:
     get:
+      operationId: getUploadStatus
       tags:
       - Quarantine
       summary: Liefert den Status der Virenprüfung.
@@ -1819,6 +1820,7 @@ paths:
                 status: 503
                 detail: Der Dienst ist zurzeit nicht verfügbar.
     delete:
+      operationId: deleteUpload
       tags:
       - Quarantine
       summary: Markiert die Datei zum Löschen
@@ -1905,6 +1907,7 @@ paths:
                 detail: Der Dienst ist zurzeit nicht verfügbar.
   /Quarantine/v1/Upload/Chunked:
     post:
+      operationId: uploadChunk
       tags:
       - Quarantine
       summary: Lädt ein Dateistück in die Quarantäne hoch
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java
deleted file mode 100644
index f5187621d37cc7b48e47a1884e0df775636fca86..0000000000000000000000000000000000000000
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachException.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.ozgcloud.nachrichten.postfach.osiv2;
-
-import de.ozgcloud.common.errorhandling.TechnicalException;
-
-public class OsiPostfachException extends TechnicalException {
-	public OsiPostfachException(String msg, Throwable cause) {
-		super(msg, cause);
-	}
-}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java
index 42c45db565ca8bb736d62827193e7d9c498bdbb4..de16ed8fbb8b0295b12c39f5cfed2fd574b17682 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java
@@ -2,49 +2,47 @@ package de.ozgcloud.nachrichten.postfach.osiv2;
 
 import java.util.stream.Stream;
 
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Service;
-
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
 import de.ozgcloud.nachrichten.postfach.PostfachRemoteService;
-import de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties;
-import de.ozgcloud.nachrichten.postfach.osiv2.transfer.PostfachApiFacadeService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2ExceptionHandler;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2PostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2PostfachService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.log4j.Log4j2;
 
-@Service
-@ConditionalOnProperty(prefix = Osi2PostfachProperties.PREFIX, name = "enabled", havingValue = "true")
+@ServiceIfOsi2Enabled
 @Log4j2
 @RequiredArgsConstructor
 public class OsiPostfachRemoteService implements PostfachRemoteService {
-	private final PostfachApiFacadeService postfachApiFacadeService;
+	private final Osi2PostfachService osi2PostfachService;
+	private final Osi2ExceptionHandler exceptionHandler;
 
 	public static final String POSTFACH_TYPE_OSI = "OSI";
 
 	@Override
 	public void sendMessage(PostfachNachricht nachricht) {
 		try {
-			postfachApiFacadeService.sendMessage(nachricht);
+			osi2PostfachService.sendMessage(nachricht);
 		} catch (RuntimeException e) {
-			throw new OsiPostfachException("Failed to send message", e);
+			throw new Osi2PostfachException("sendMessage failed!", exceptionHandler.deriveMessageCode(e), e);
 		}
 	}
 
 	@Override
 	public Stream<PostfachNachricht> getAllMessages() {
 		try {
-			return postfachApiFacadeService.receiveMessages();
+			return osi2PostfachService.receiveMessages();
 		} catch (RuntimeException e) {
-			throw new OsiPostfachException("Failed to get messages", e);
+			throw new Osi2PostfachException("getAllMessages failed!", exceptionHandler.deriveMessageCode(e), e);
 		}
 	}
 
 	@Override
 	public void deleteMessage(String messageId) {
 		try {
-			postfachApiFacadeService.deleteMessage(messageId);
+			osi2PostfachService.deleteMessage(messageId);
 		} catch (RuntimeException e) {
-			throw new OsiPostfachException("Failed to delete message", e);
+			throw new Osi2PostfachException("deleteMessage failed!", exceptionHandler.deriveMessageCode(e), e);
 		}
 	}
 
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/ServiceIfOsi2Enabled.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/ServiceIfOsi2Enabled.java
new file mode 100644
index 0000000000000000000000000000000000000000..a49709999ad359ed7bff2cb00b060fd453cee18b
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/ServiceIfOsi2Enabled.java
@@ -0,0 +1,18 @@
+package de.ozgcloud.nachrichten.postfach.osiv2;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Service
+@ConditionalOnProperty(prefix = Osi2PostfachProperties.PREFIX, name = "enabled", havingValue = "true")
+public @interface ServiceIfOsi2Enabled {
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/ConcatInputStream.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/ConcatInputStream.java
new file mode 100644
index 0000000000000000000000000000000000000000..3ba1da4aa6eb706087181972af2565e465037594
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/ConcatInputStream.java
@@ -0,0 +1,57 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Iterator;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+class ConcatInputStream extends InputStream {
+	private final Iterator<? extends InputStream> streams;
+
+	private InputStream currentStream = null;
+
+	@Override
+	public int read() throws IOException {
+		var buffer = new byte[1];
+		var result = read(buffer, 0, 1);
+		return result < 0 ? result : buffer[0];
+	}
+
+	private boolean useNextStreamFails() throws IOException {
+		close();
+		if (streams.hasNext()) {
+			currentStream = streams.next();
+			return false;
+		}
+		return true;
+	}
+
+	@Override
+	public int read(byte[] buffer, int offset, int length) throws IOException {
+		if (currentStream == null && useNextStreamFails()) {
+			return -1;
+		}
+		var remainingLength = length;
+		while (remainingLength > 0) {
+			var readLength = currentStream.read(buffer, offset, remainingLength);
+			if (readLength > 0) {
+				remainingLength -= readLength;
+				offset += readLength;
+			} else if (useNextStreamFails()) {
+				break;
+			}
+		}
+		var totalReadLength = length - remainingLength;
+		return totalReadLength >= 0 ? totalReadLength : -1;
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (currentStream != null) {
+			currentStream.close();
+			currentStream = null;
+		}
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/LimitedInputStream.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/LimitedInputStream.java
new file mode 100644
index 0000000000000000000000000000000000000000..430c249c7787fd457170926c408dec0dbe6834e2
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/LimitedInputStream.java
@@ -0,0 +1,38 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class LimitedInputStream extends InputStream {
+	private final InputStream parentStream;
+	private final long limit;
+	private long readOffset = 0;
+
+	@Override
+	public int read() throws IOException {
+		var buffer = new byte[1];
+		var result = read(buffer, 0, 1);
+		return result < 0 ? result : buffer[0];
+	}
+
+	@Override
+	public int read(byte[] buffer, int offset, int length) throws IOException {
+		var remainingLength = limit - readOffset;
+		if (remainingLength > 0) {
+			var readLength = parentStream.read(buffer, offset, (int) Math.min(remainingLength, length));
+			if (readLength > 0) {
+				readOffset += readLength;
+				return readLength;
+			}
+		}
+		return -1;
+	}
+
+	@Override
+	public void close() {
+		// Keep parent stream open
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileMapper.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileMapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..d6c3f4a650362fb6c33a881d32c03c8289a042c5
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileMapper.java
@@ -0,0 +1,29 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ReportingPolicy;
+
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.apilib.file.OzgCloudFileId;
+import de.ozgcloud.apilib.file.OzgCloudUploadFile;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import de.ozgcloud.vorgang.grpc.file.GrpcOzgFile;
+
+@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
+public interface Osi2AttachmentFileMapper {
+
+	// From de.ozgcloud.nachrichten.file.AttachmentFileService
+	String ATTACHMENT_FIELD_NAME = "PostfachAttachment";
+
+	OzgCloudFile mapToUploadFileMetadata(GrpcOzgFile grpcOzgFile);
+
+	default OzgCloudFileId mapToUploadFileId(String fileId) {
+		return new OzgCloudFileId(fileId);
+	}
+
+	@Mapping(target = "fileName", source = "name")
+	@Mapping(target = "fieldName", constant = ATTACHMENT_FIELD_NAME)
+	OzgCloudUploadFile toOzgCloudUploadFile(AttachmentFile attachmentFile);
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileService.java
new file mode 100644
index 0000000000000000000000000000000000000000..fa0c2c0b0243d43b3bde5c466250db4ac397c993
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileService.java
@@ -0,0 +1,80 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties.*;
+
+import java.io.InputStream;
+import java.util.Iterator;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+
+import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextAttachingInterceptor;
+import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextProvider;
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.apilib.file.OzgCloudFileService;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcBinaryFilesRequest;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcGetBinaryFileDataRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import net.devh.boot.grpc.client.inject.GrpcClient;
+
+@Log4j2
+@ServiceIfOsi2Enabled
+@RequiredArgsConstructor
+public class Osi2AttachmentFileService {
+
+	private BinaryFileServiceGrpc.BinaryFileServiceBlockingStub binaryFileServiceStub;
+	private final Osi2AttachmentFileMapper attachmentFileMapper;
+	@Qualifier(OZG_CLOUD_FILE_SERVICE_NAME)
+	private final OzgCloudFileService ozgCloudFileService;
+	private final OzgCloudCallContextProvider ozgCloudCallContextProvider;
+
+	@GrpcClient(GRPC_FILE_MANAGER_NAME)
+	public void setBinaryFileServiceStub(BinaryFileServiceGrpc.BinaryFileServiceBlockingStub binaryFilServiceStubWithoutInterceptor) {
+		this.binaryFileServiceStub = binaryFilServiceStubWithoutInterceptor
+				.withInterceptors(new OzgCloudCallContextAttachingInterceptor(ozgCloudCallContextProvider));
+	}
+
+	public List<OzgCloudFile> getFileMetadataOfIds(List<String> fileIds) {
+		return binaryFileServiceStub.findBinaryFilesMetaData(createRequestWithFileIds(fileIds))
+				.getFileList()
+				.stream()
+				.map(attachmentFileMapper::mapToUploadFileMetadata)
+				.toList();
+	}
+
+	GrpcBinaryFilesRequest createRequestWithFileIds(List<String> fileIds) {
+		return GrpcBinaryFilesRequest.newBuilder().addAllFileId(fileIds).build();
+	}
+
+	public String uploadFileAndReturnId(AttachmentFile attachmentFile, InputStream fileInputStream) {
+		var attachmentFileId = ozgCloudFileService.uploadFile(
+				attachmentFileMapper.toOzgCloudUploadFile(attachmentFile),
+				fileInputStream
+		);
+		return attachmentFileId.toString();
+	}
+
+	public InputStream downloadFileContent(String fileId) {
+		var response = binaryFileServiceStub.getBinaryFileContent(createRequestWithFileId(fileId));
+		return new ConcatInputStream(new Iterator<>() {
+			@Override
+			public boolean hasNext() {
+				return response.hasNext();
+			}
+
+			@Override
+			public InputStream next() {
+				return response.next().getFileContent().newInput();
+			}
+		});
+	}
+
+	GrpcGetBinaryFileDataRequest createRequestWithFileId(String fileId) {
+		return GrpcGetBinaryFileDataRequest.newBuilder().setFileId(fileId).build();
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2PersistAttachmentService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2PersistAttachmentService.java
new file mode 100644
index 0000000000000000000000000000000000000000..5e56103a281e039cd456c88b50d0020e787f061f
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2PersistAttachmentService.java
@@ -0,0 +1,52 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.core.io.Resource;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+import de.ozgcloud.nachrichten.postfach.osiv2.transfer.PostfachApiFacadeService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+@ServiceIfOsi2Enabled
+@RequiredArgsConstructor
+public class Osi2PersistAttachmentService {
+	private final PostfachApiFacadeService postfachApiFacadeService;
+	private final Osi2AttachmentFileService attachmentFileService;
+
+	public List<String> persistAttachments(Osi2Message osi2Message) {
+		return osi2Message.attachments().stream()
+				.map(info -> persistAttachment(osi2Message, info))
+				.toList();
+	}
+
+	String persistAttachment(Osi2Message osi2Message, AttachmentInfo attachment) {
+		return persistAttachmentFile(
+				buildAttachmentFile(osi2Message.vorgangId(), attachment),
+				postfachApiFacadeService.downloadAttachment(osi2Message.messageId(), attachment.guid())
+		);
+	}
+
+	AttachmentFile buildAttachmentFile(String vorgangId, AttachmentInfo attachment) {
+		return AttachmentFile.builder()
+				.name(attachment.name())
+				.contentType(attachment.contentType())
+				.vorgangId(vorgangId)
+				.build();
+	}
+
+	String persistAttachmentFile(AttachmentFile attachmentFile, Resource content) {
+		try (var inputStream = content.getInputStream()) {
+			return attachmentFileService.uploadFileAndReturnId(attachmentFile, inputStream);
+		} catch (IOException e) {
+			throw new Osi2RuntimeException("Error while persisting attachment", e);
+		}
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/ApiClientConfiguration.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/ApiClientConfiguration.java
index e9948c36a99efc62d6d03c533da3971a5c515438..6b28b57315c3cce2c26f9e88c6275fafe3f989a4 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/ApiClientConfiguration.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/ApiClientConfiguration.java
@@ -11,7 +11,6 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.http.client.ClientHttpRequestFactory;
 import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
 import org.springframework.http.converter.FormHttpMessageConverter;
-import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
 import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
 import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
@@ -31,8 +30,12 @@ import org.springframework.web.client.RestClient;
 
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.ApiClient;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.MessageExchangeApi;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.QuarantineApi;
 import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
 
+@Log4j2
 @Configuration
 @RequiredArgsConstructor
 @ConditionalOnProperty(prefix = Osi2PostfachProperties.PREFIX, name = "enabled", havingValue = "true")
@@ -50,8 +53,15 @@ public class ApiClientConfiguration {
 	}
 
 	@Bean
+	QuarantineApi quarantineApi(ApiClient apiClient) {
+		return new QuarantineApi(apiClient);
+	}
+
+	@Bean
+	@SneakyThrows
 	ApiClient apiClient() {
 		var apiClient = new ApiClient(restClient());
+		LOG.info("Setting api client base path to {}", apiConfiguration.getUrl());
 		apiClient.setBasePath(apiConfiguration.getUrl());
 		return apiClient;
 	}
@@ -73,6 +83,7 @@ public class ApiClientConfiguration {
 	private ClientHttpRequestFactory createProxyRequestFactory() {
 		var requestFactory = new HttpComponentsClientHttpRequestFactory();
 		if (proxyConfiguration.isEnabled()) {
+			LOG.info("Using proxy configuration: {}:{}", proxyConfiguration.getHost(), proxyConfiguration.getPort());
 			requestFactory.setHttpClient(
 					HttpClientBuilder.create()
 							.setProxy(new HttpHost(proxyConfiguration.getHost(), proxyConfiguration.getPort()))
@@ -88,6 +99,7 @@ public class ApiClientConfiguration {
 		var username = proxyConfiguration.getUsername();
 		var password = proxyConfiguration.getPassword();
 		if (username != null && password != null) {
+			LOG.info("Using proxy authentication with username {}", username);
 			credentialsProvider.setCredentials(new AuthScope(proxyConfiguration.getHost(), proxyConfiguration.getPort()),
 					new UsernamePasswordCredentials(username, password.toCharArray()));
 		}
@@ -111,6 +123,8 @@ public class ApiClientConfiguration {
 	}
 
 	private ClientRegistration osi2ClientRegistration() {
+		LOG.info("Creating client registration with clientId: {}, tokenUri: {}, scope: {}, resource: {}", authConfiguration.getClientId(),
+				authConfiguration.getTokenUri(), authConfiguration.getScope(), authConfiguration.getResource());
 		return ClientRegistration.withRegistrationId(CLIENT_REGISTRATION_ID)
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/Osi2PostfachProperties.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/Osi2PostfachProperties.java
index f670ec7a5c42c51c5953aa1703ff2485b1beb3aa..e4b53fb59502e8f3afcc3b05066d4a400afb3da6 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/Osi2PostfachProperties.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/Osi2PostfachProperties.java
@@ -3,21 +3,25 @@ package de.ozgcloud.nachrichten.postfach.osiv2.config;
 import java.util.List;
 
 import jakarta.annotation.Nullable;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
 
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.context.annotation.Configuration;
 
 import lombok.Getter;
-import lombok.RequiredArgsConstructor;
 import lombok.Setter;
 
 @Getter
 @Setter
 @Configuration
-@ConditionalOnProperty(prefix = Osi2PostfachProperties.PREFIX, name = "enabled", havingValue = "true")
-@RequiredArgsConstructor
+@ConfigurationProperties(prefix = Osi2PostfachProperties.PREFIX)
 public class Osi2PostfachProperties {
+	// From de.ozgcloud.nachrichten.NachrichtenManagerConfiguration
+	public static final String GRPC_FILE_MANAGER_NAME = "file-manager";
+	// From de.ozgcloud.nachrichten.NachrichtenManagerConfiguration
+	public static final String OZG_CLOUD_FILE_SERVICE_NAME = "nachrichten_OzgCloudFileService";
 
 	public static final String PREFIX = "ozgcloud.osiv2";
 
@@ -30,10 +34,16 @@ public class Osi2PostfachProperties {
 	public static class AuthConfiguration {
 		public static final String PREFIX = Osi2PostfachProperties.PREFIX + ".auth";
 
+		@NotBlank
 		private String clientId;
+		@NotBlank
 		private String clientSecret;
-		private List<String> scope;
+		@NotNull
+		@Valid
+		private List<@NotBlank String> scope;
+		@NotBlank
 		private String tokenUri;
+		@NotBlank
 		private String resource;
 	}
 
@@ -44,8 +54,11 @@ public class Osi2PostfachProperties {
 	public static class ApiConfiguration {
 		public static final String PREFIX = Osi2PostfachProperties.PREFIX + ".api";
 
+		@NotBlank
 		private String url;
+		@NotBlank
 		private String tenant;
+		@NotBlank
 		private String nameIdentifier;
 	}
 
@@ -62,7 +75,9 @@ public class Osi2PostfachProperties {
 
 		private boolean enabled;
 
+		@NotBlank
 		private String host;
+		@NotNull
 		private Integer port;
 
 		@Nullable
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java
new file mode 100644
index 0000000000000000000000000000000000000000..395f4272265f5695cb06f9a0cd91ae9ddc396949
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandler.java
@@ -0,0 +1,37 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import jakarta.annotation.Nullable;
+
+import org.springframework.http.HttpStatusCode;
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestClientResponseException;
+
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+
+@ServiceIfOsi2Enabled
+public class Osi2ExceptionHandler {
+
+	public PostfachMessageCode deriveMessageCode(@Nullable RuntimeException exception) {
+		if (exception instanceof RestClientResponseException restClientResponseException) {
+			return deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+		}
+		if (exception instanceof ResourceAccessException) {
+			return PostfachMessageCode.SERVER_CONNECTION_FAILED_MESSAGE_CODE;
+		}
+		return PostfachMessageCode.PROCESS_FAILED_MESSAGE_CODE;
+	}
+
+	PostfachMessageCode deriveMessageCodeFromRestClientResponseException(RestClientResponseException restClientResponseException) {
+		if (hasStatusNotFound(restClientResponseException)) {
+			return PostfachMessageCode.SEND_FAILED_UNKNOWN_POSTFACH_ID_MESSAGE_CODE;
+		}
+		return PostfachMessageCode.PROCESS_FAILED_MESSAGE_CODE;
+	}
+
+	private boolean hasStatusNotFound(RestClientResponseException restClientResponseException) {
+		return restClientResponseException.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(404));
+	}
+
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java
new file mode 100644
index 0000000000000000000000000000000000000000..1fae9a09afc9608a8a6f9943dbca0ec320cb1859
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2PostfachException.java
@@ -0,0 +1,10 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import de.ozgcloud.nachrichten.postfach.PostfachException;
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
+
+public class Osi2PostfachException extends PostfachException {
+	public Osi2PostfachException(String msg, PostfachMessageCode messageCode, Throwable cause) {
+		super(msg, messageCode, cause);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java
new file mode 100644
index 0000000000000000000000000000000000000000..6f1966d63bfb58c42ac643afec10ca9c4ebb336b
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2RuntimeException.java
@@ -0,0 +1,12 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+public class Osi2RuntimeException extends RuntimeException {
+	public Osi2RuntimeException(String msg) {
+		super(msg);
+	}
+
+	public Osi2RuntimeException(String msg, Throwable cause) {
+		super(msg, cause);
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
new file mode 100644
index 0000000000000000000000000000000000000000..10b9f2a97c575b102427ead2234cd3e77e244b3a
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2UploadException.java
@@ -0,0 +1,13 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
+
+public class Osi2UploadException extends Exception {
+	public Osi2UploadException(QuarantineStatus uploadStatus) {
+		super(uploadStatus.toString());
+	}
+
+	public boolean isUnsafe() {
+		return QuarantineStatus.fromValue(getMessage()) == QuarantineStatus.UNSAFE;
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/AttachmentFile.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/AttachmentFile.java
new file mode 100644
index 0000000000000000000000000000000000000000..2486622be46ceabda290edd6bd8b3c52503d5c3c
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/AttachmentFile.java
@@ -0,0 +1,10 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.model;
+import lombok.Builder;
+
+@Builder
+public record AttachmentFile(
+		String name,
+		String contentType,
+		String vorgangId
+) {
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/AttachmentInfo.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/AttachmentInfo.java
new file mode 100644
index 0000000000000000000000000000000000000000..5d204b1c0991b12a394199b99919521492c14b1c
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/AttachmentInfo.java
@@ -0,0 +1,12 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.model;
+
+import lombok.Builder;
+
+@Builder
+public record AttachmentInfo(
+		String guid,
+		String name,
+		String contentType,
+		Long size
+) {
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/FileChunkInfo.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/FileChunkInfo.java
new file mode 100644
index 0000000000000000000000000000000000000000..a119cbaf62538ee51a8f5c30e680936a06d207f8
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/FileChunkInfo.java
@@ -0,0 +1,41 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.model;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment.*;
+
+import java.io.InputStream;
+
+import org.springframework.core.io.AbstractResource;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.LimitedInputStream;
+import lombok.Builder;
+
+@Builder
+public record FileChunkInfo(
+		Osi2Attachment upload,
+		long chunkIndex
+) {
+	public AbstractResource createUploadResource(InputStream fileInputStream) {
+		return new AbstractResource() {
+			@Override
+			public String getDescription() {
+				return "File chunk " + chunkIndex + " of " + upload.getLoggableString();
+			}
+
+			@Override
+			public InputStream getInputStream() {
+				return new LimitedInputStream(fileInputStream, contentLength());
+			}
+
+			@Override
+			public long contentLength() {
+				return chunkIndex == upload.numberOfChunks() ? 0 : getChunkContentLength();
+			}
+
+			private long getChunkContentLength() {
+				return chunkIndex == upload.numberOfChunks() - 1
+						? upload.file().getSize() % CHUNK_SIZE
+						: CHUNK_SIZE;
+			}
+		};
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2Attachment.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2Attachment.java
new file mode 100644
index 0000000000000000000000000000000000000000..dc41ec7685a33e83990ce954b9097e523dfe57cc
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2Attachment.java
@@ -0,0 +1,29 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.model;
+
+import java.util.UUID;
+
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import lombok.Builder;
+
+@Builder
+public record Osi2Attachment(
+		String guid,
+		OzgCloudFile file
+) {
+	public static final long CHUNK_SIZE = 100L * (2L << 10);
+
+	public static Osi2Attachment from(OzgCloudFile file) {
+		return Osi2Attachment.builder()
+				.guid(UUID.randomUUID().toString())
+				.file(file)
+				.build();
+	}
+
+	public long numberOfChunks() {
+		return (file.getSize() + CHUNK_SIZE - 1) / CHUNK_SIZE;
+	}
+
+	public String getLoggableString() {
+		return String.format("Attachment(messageGuid=%s, ozgFileId=%s, size=%d)", guid, file.getId(), file.getSize());
+	}
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2Message.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2Message.java
new file mode 100644
index 0000000000000000000000000000000000000000..f527fceac71309d1bc39f427e1e8e08f367ce89c
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2Message.java
@@ -0,0 +1,21 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.model;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import de.ozgcloud.nachrichten.postfach.PostfachAddress;
+import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import lombok.Builder;
+
+@Builder
+public record Osi2Message(
+		String vorgangId,
+		PostfachAddress postfachAddress,
+		String messageId,
+		ZonedDateTime createdAt,
+		String subject,
+		String mailBody,
+		PostfachNachricht.ReplyOption replyOption,
+		List<AttachmentInfo> attachments
+) {
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2MessageMapper.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2MessageMapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..7d81f6f6a4a39d30952f08314ce8204ea72d6562
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2MessageMapper.java
@@ -0,0 +1,26 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import java.util.List;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ReportingPolicy;
+
+import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+
+@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
+public interface Osi2MessageMapper {
+	@Mapping(target = "id", ignore = true)
+	@Mapping(target = "referencedNachricht", ignore = true)
+
+	@Mapping(target = "createdBy", ignore = true)
+
+	@Mapping(target = "sentAt", ignore = true)
+	@Mapping(target = "sentSuccessful", ignore = true)
+	@Mapping(target = "messageCode", ignore = true)
+
+	@Mapping(target = "attachments", source = "attachmentIds")
+	@Mapping(target = "direction", constant = "IN")
+	PostfachNachricht toPostfachNachricht(Osi2Message osi2Message, List<String> attachmentIds);
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2PostfachService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2PostfachService.java
new file mode 100644
index 0000000000000000000000000000000000000000..754a9458fcf09d45e7ea55bd5c18e574b10de913
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2PostfachService.java
@@ -0,0 +1,45 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import java.util.stream.Stream;
+
+import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.Osi2PersistAttachmentService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+@ServiceIfOsi2Enabled
+@RequiredArgsConstructor
+public class Osi2PostfachService {
+	private final PostfachApiFacadeService postfachApiFacadeService;
+	private final Osi2QuarantineService quarantineService;
+	private final Osi2PersistAttachmentService persistAttachmentService;
+	private final Osi2MessageMapper messageMapper;
+
+	public void sendMessage(PostfachNachricht nachricht) {
+		postfachApiFacadeService.sendMessage(
+				nachricht,
+				quarantineService.uploadAttachments(nachricht.getAttachments())
+		);
+	}
+
+	public Stream<PostfachNachricht> receiveMessages() {
+		return postfachApiFacadeService.fetchPendingMessageIds()
+				.stream()
+				.map(this::fetchPostfachNachricht);
+	}
+
+	PostfachNachricht fetchPostfachNachricht(String messageGuid) {
+		var message = postfachApiFacadeService.fetchMessageById(messageGuid);
+		return messageMapper.toPostfachNachricht(
+				message,
+				persistAttachmentService.persistAttachments(message)
+		);
+	}
+
+	public void deleteMessage(final String messageId) {
+		postfachApiFacadeService.deleteMessage(messageId);
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java
new file mode 100644
index 0000000000000000000000000000000000000000..b36dc7f60e6cbf9c937d8e32c49463eff1042b79
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineService.java
@@ -0,0 +1,119 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.WaitUtil.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.LongStream;
+import java.util.stream.Stream;
+
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.Osi2AttachmentFileService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+@ServiceIfOsi2Enabled
+@RequiredArgsConstructor
+public class Osi2QuarantineService {
+	private final PostfachApiFacadeService postfachApiFacadeService;
+	private final Osi2AttachmentFileService binaryFileService;
+
+	static final Duration POLLING_INTERVAL = Duration.ofSeconds(1);
+	static final Duration POLLING_TIMEOUT = Duration.ofMinutes(5);
+
+	public List<Osi2Attachment> uploadAttachments(List<String> attachmentIds) {
+		return uploadFiles(binaryFileService.getFileMetadataOfIds(attachmentIds));
+	}
+
+	List<Osi2Attachment> uploadFiles(List<OzgCloudFile> ozgCloudFiles) {
+		var sortedUploadFiles = deriveSortedUploadFiles(ozgCloudFiles);
+		try {
+			tryUploadSortedFiles(sortedUploadFiles);
+			return sortedUploadFiles;
+		} catch (RuntimeException e) {
+			deleteAttachments(sortedUploadFiles);
+			throw e;
+		}
+	}
+
+	List<Osi2Attachment> deriveSortedUploadFiles(List<OzgCloudFile> ozgCloudFiles) {
+		return ozgCloudFiles.stream()
+				.sorted(Comparator.comparing(OzgCloudFile::getSize).reversed())
+				.map(Osi2Attachment::from)
+				.toList();
+	}
+
+	void tryUploadSortedFiles(List<Osi2Attachment> sortedUploadFiles) {
+		uploadFilesToQuarantine(sortedUploadFiles);
+		waitForVirusScan(sortedUploadFiles);
+	}
+
+	void uploadFilesToQuarantine(List<Osi2Attachment> uploads) {
+		uploads.forEach(this::uploadFileToQuarantine);
+	}
+
+	void uploadFileToQuarantine(Osi2Attachment upload) {
+		try (var fileInputStream = binaryFileService.downloadFileContent(upload.file().getId().toString())) {
+			uploadInputStreamToQuarantine(upload, fileInputStream);
+		} catch (IOException e) {
+			throw new Osi2RuntimeException("Failed to close upload input stream!", e);
+		}
+	}
+
+	void uploadInputStreamToQuarantine(Osi2Attachment upload, InputStream fileInputStream) {
+		streamFileChunkInfos(upload)
+				.forEachOrdered(chunkInfo ->
+						postfachApiFacadeService.uploadChunk(
+								chunkInfo,
+								chunkInfo.createUploadResource(fileInputStream)
+						)
+				);
+	}
+
+	Stream<FileChunkInfo> streamFileChunkInfos(Osi2Attachment upload) {
+		return LongStream.range(0, upload.numberOfChunks() + 1)
+				.mapToObj(chunkIndex -> buildFileChunkInfo(upload, chunkIndex));
+	}
+
+	private FileChunkInfo buildFileChunkInfo(Osi2Attachment upload, long chunkIndex) {
+		return FileChunkInfo.builder()
+				.upload(upload)
+				.chunkIndex(chunkIndex)
+				.build();
+	}
+
+	void waitForVirusScan(List<Osi2Attachment> osi2FileMetadata) {
+		if (!waitUntil(() -> checkVirusScanCompleted(osi2FileMetadata), POLLING_INTERVAL, POLLING_TIMEOUT)) {
+			throw new Osi2RuntimeException("Expect the scan to complete after %d seconds!".formatted(POLLING_TIMEOUT.getSeconds()));
+		}
+	}
+
+	synchronized boolean checkVirusScanCompleted(List<Osi2Attachment> osi2FileMetadata) {
+		return osi2FileMetadata.stream()
+				.allMatch(this::checkOneVirusScanCompleted);
+	}
+
+	boolean checkOneVirusScanCompleted(Osi2Attachment osi2FileMetadata) {
+		try {
+			return postfachApiFacadeService.checkUploadSuccessful(osi2FileMetadata.guid());
+		} catch (Osi2UploadException e) {
+			throw new Osi2RuntimeException("%s failed!".formatted(osi2FileMetadata.getLoggableString()), e);
+		}
+	}
+
+	void deleteAttachments(List<Osi2Attachment> uploads) {
+		uploads.stream()
+				.map(Osi2Attachment::guid)
+				.forEach(postfachApiFacadeService::deleteFileUpload);
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapper.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapper.java
index c2aab079e54ede89a9b32c09d5791e663283b71f..86aa86d13ffd80d919a5f4c8b92ae31c4d39bf22 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapper.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapper.java
@@ -2,7 +2,11 @@ package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 import org.mapstruct.Mapper;
 import org.mapstruct.Mapping;
@@ -11,30 +15,55 @@ import org.mapstruct.ReportingPolicy;
 import de.ozgcloud.nachrichten.postfach.PostfachAddress;
 import de.ozgcloud.nachrichten.postfach.PostfachAddressIdentifier;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.DomainChunkMetaData;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeFiles;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.OutSendMessageRequestV2;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1References;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyBehavior;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
 
 @Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
 public interface Osi2RequestMapper {
 
-	@Mapping(target = "sequencenumber", source = "vorgangId")
-	@Mapping(target = "body", source = "mailBody")
+	int MAX_NUMBER_RECEIVED_MESSAGES = 100;
+
+	@Mapping(target = "sequencenumber", source = "nachricht.vorgangId")
+	@Mapping(target = "body", source = "nachricht.mailBody")
 	@Mapping(target = "displayName", ignore = true)
 	@Mapping(target = "originSender", ignore = true)
-	@Mapping(target = "replyAction", source = "replyOption")
+	@Mapping(target = "replyAction", source = "nachricht.replyOption")
 	@Mapping(target = "eidasLevel", constant = "LOW")
 	@Mapping(target = "isObligatory", expression = "java( false )")
 	@Mapping(target = "isHtml", expression = "java( false )")
-	@Mapping(target = "files", expression = "java( mapMessageExchangeFiles() )")
+	@Mapping(target = "files", expression = "java( mapMessageExchangeFiles(nachricht, files) )")
 	@Mapping(target = "references", expression = "java( mapReferences() )")
-	OutSendMessageRequestV2 mapOutSendMessageRequestV2(PostfachNachricht nachricht);
+	OutSendMessageRequestV2 mapOutSendMessageRequestV2(PostfachNachricht nachricht, List<Osi2Attachment> files);
 
-	default List<MessageExchangeFiles> mapMessageExchangeFiles() {
-		return Collections.emptyList();
+	default List<MessageExchangeFiles> mapMessageExchangeFiles(PostfachNachricht nachricht, List<Osi2Attachment> files) {
+		var filesById = associateUploadWithAttachmentId(files);
+		return nachricht.getAttachments()
+				.stream()
+				.map(fileId -> Objects.requireNonNull(
+						filesById.get(fileId),
+						"Expect all attachmentIds are uploaded!"
+				))
+				.map(this::mapMessageExchangeFile)
+				.toList();
+	}
+
+	default Map<String, Osi2Attachment> associateUploadWithAttachmentId(List<Osi2Attachment> uploads) {
+		return uploads.stream()
+				.filter(upload -> upload.file() != null && upload.file().getId() != null)
+				.collect(Collectors.toMap(upload -> upload.file().getId().toString(), Function.identity()));
 	}
 
+	@Mapping(target = "mimeType", source = "file.contentType")
+	@Mapping(target = "name", source = "file.name")
+	@Mapping(target = "size", source = "file.size")
+	@Mapping(target = "isOriginalMessage", expression = "java( false )")
+	MessageExchangeFiles mapMessageExchangeFile(Osi2Attachment fileUpload);
+
 	default List<V1References> mapReferences() {
 		return Collections.emptyList();
 	}
@@ -47,13 +76,19 @@ public interface Osi2RequestMapper {
 		};
 	}
 
+	@Mapping(target = "target", constant = "UNSPECIFIED")
+	@Mapping(target = "uploadUid", source = "upload.guid")
+	@Mapping(target = "fileName", source = "upload.file.name")
+	@Mapping(target = "contentType", source = "upload.file.contentType")
+	@Mapping(target = "totalChunks", expression = "java( (int) fileChunkInfo.upload().numberOfChunks() )")
+	@Mapping(target = "totalFileSize", source = "upload.file.size")
+	DomainChunkMetaData mapDomainChunkMetaData(FileChunkInfo fileChunkInfo);
+
 	default String mapMailboxId(PostfachNachricht nachricht) {
 		return Optional.ofNullable(nachricht.getPostfachAddress())
 				.map(PostfachAddress::getIdentifier)
-				.filter(PostfachAddressIdentifier::isStringBasedIdentifier)
-				.map(Object::toString)
-				.orElseThrow(() -> new IllegalArgumentException(
-						"Missing MailboxId! Expect MailboxId to be a string-based PostfachAddress of PostfachNachricht."));
+				.map(PostfachAddressIdentifier::getStringRepresentation)
+				.orElseThrow(() -> new IllegalArgumentException("Missing MailboxId!"));
 	}
 
 }
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java
index ef8d1ea7f2d5e9d28499e8f05badc783b0854fc5..74a34e0487b9e48ef2ee046d8a5f1ac707778fe6 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapper.java
@@ -2,6 +2,10 @@ package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
 import java.time.OffsetDateTime;
 import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.UUID;
 
 import org.mapstruct.Mapper;
@@ -10,13 +14,21 @@ import org.mapstruct.Named;
 import org.mapstruct.ReportingPolicy;
 
 import de.ozgcloud.nachrichten.postfach.PostfachAddress;
-import de.ozgcloud.nachrichten.postfach.PostfachAddressIdentifier;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.StringBasedIdentifier;
 import de.ozgcloud.nachrichten.postfach.osiv2.OsiPostfachRemoteService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveAttachment;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessage;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessagesResponse;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineFileResult;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyBehavior;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyFiles;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyMessage;
-import lombok.Builder;
-import lombok.Getter;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
 
 @Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR, imports = { Osi2HtmlDocument.class })
 public interface Osi2ResponseMapper {
@@ -24,26 +36,18 @@ public interface Osi2ResponseMapper {
 	String POSTFACH_ADDRESS_VERSION = "2.0";
 	int POSTFACH_ADDRESS_TYPE = 2;
 
-	@Mapping(target = "id", ignore = true)
 	@Mapping(target = "vorgangId", source = "sequencenumber")
 	@Mapping(target = "postfachAddress", source = "messageBox")
 	@Mapping(target = "messageId", source = "guid")
-	@Mapping(target = "referencedNachricht", ignore = true)
-
 	@Mapping(target = "createdAt", source = "responseTime", qualifiedByName = "mapOffsetDateTimeToZoned")
-	@Mapping(target = "createdBy", ignore = true)
-
-	@Mapping(target = "sentAt", ignore = true)
-	@Mapping(target = "sentSuccessful", ignore = true)
-	@Mapping(target = "messageCode", ignore = true)
-
-	@Mapping(target = "direction", constant = "IN")
-
 	@Mapping(target = "subject", source = "subject")
 	@Mapping(target = "mailBody", source = ".", qualifiedByName = "mapMailBody")
 	@Mapping(target = "replyOption", source = "replyAction")
-	@Mapping(target = "attachments", ignore = true)
-	PostfachNachricht toPostfachNachricht(V1ReplyMessage message);
+	@Mapping(target = "attachments", source = "files", defaultExpression = "java(List.of())")
+	Osi2Message toMessage(V1ReplyMessage message);
+
+	@Mapping(target = "contentType", source = "mimeType")
+	AttachmentInfo toAttachmentInfo(V1ReplyFiles attachment);
 
 	default String mapNullToEmpty(String value) {
 		return value == null ? "" : value;
@@ -69,7 +73,7 @@ public interface Osi2ResponseMapper {
 						.type(POSTFACH_ADDRESS_TYPE)
 						.version(POSTFACH_ADDRESS_VERSION)
 						.identifier(StringBasedIdentifier.builder()
-								.mailboxId(messageBox.toString())
+								.postfachId(messageBox.toString())
 								.build())
 						.serviceKontoType(OsiPostfachRemoteService.POSTFACH_TYPE_OSI)
 						.build();
@@ -86,20 +90,41 @@ public interface Osi2ResponseMapper {
 				};
 	}
 
-	@Builder
-	@Getter
-	class StringBasedIdentifier implements PostfachAddressIdentifier {
+	default List<String> toMessageIds(MessageExchangeReceiveMessagesResponse response) {
+		if (response == null) {
+			throw new Osi2RuntimeException("Expect non empty response!", null);
+		}
+
+		return Optional.ofNullable(response.getMessages())
+				.stream()
+				.flatMap(Collection::stream)
+				.map(MessageExchangeReceiveMessage::getGuid)
+				.filter(Objects::nonNull)
+				.map(UUID::toString)
+				.toList();
+	}
 
-		private String mailboxId;
+	default boolean isSafe(QuarantineStatus uploadStatus) throws Osi2UploadException {
+		return switch (uploadStatus) {
+			case NONE, UNSAFE, CORRUPT, MISSING -> throw new Osi2UploadException(uploadStatus);
+			case LEGACY, COMMITED -> false;
+			case SAFE -> true;
+		};
+	}
 
-		@Override
-		public boolean isStringBasedIdentifier() {
-			return true;
+	default void checkChunkUploadSuccess(QuarantineFileResult quarantineFileResult) {
+		if (!Boolean.TRUE.equals(quarantineFileResult.getSuccess())) {
+			throw new Osi2RuntimeException(
+					"Chunk-Upload of file %s failed: %s".formatted(quarantineFileResult.getFileUid(), quarantineFileResult.getError()), null);
 		}
+	}
 
-		@Override
-		public String toString() {
-			return mailboxId;
-		}
+	@Named("mapMessageExchangeReceiveAttachment")
+	default String mapMessageExchangeReceiveAttachment(MessageExchangeReceiveAttachment attachment) {
+		return Optional.ofNullable(attachment)
+				.map(MessageExchangeReceiveAttachment::getGuid)
+				.map(UUID::toString)
+				.orElse(null);
 	}
+
 }
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java
index e1446a7fe8bdeaf2c4eb7e39b66324650f135314..989ae00cba9f3caa4f564fc9ac37b77e323a6efa 100644
--- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeService.java
@@ -1,59 +1,78 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
-import java.util.Collection;
-import java.util.Optional;
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2RequestMapper.*;
+
+import java.util.List;
 import java.util.UUID;
-import java.util.stream.Stream;
 
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Service;
+import org.springframework.core.io.AbstractResource;
+import org.springframework.core.io.Resource;
 
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
-import de.ozgcloud.nachrichten.postfach.osiv2.OsiPostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.ServiceIfOsi2Enabled;
 import de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.MessageExchangeApi;
-import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessage;
-import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessagesResponse;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.QuarantineApi;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.log4j.Log4j2;
 
 @Log4j2
-@Service
-@ConditionalOnProperty(prefix = Osi2PostfachProperties.PREFIX, name = "enabled", havingValue = "true")
+@ServiceIfOsi2Enabled
 @RequiredArgsConstructor
 public class PostfachApiFacadeService {
 
 	private final MessageExchangeApi messageExchangeApi;
-	private final Osi2RequestMapper osi2RequestMapper;
-	private final Osi2ResponseMapper osi2ResponseMapper;
-
-	static final int MAX_NUMBER_RECEIVED_MESSAGES = 100;
+	private final QuarantineApi quarantineApi;
+	private final Osi2RequestMapper requestMapper;
+	private final Osi2ResponseMapper responseMapper;
+	private final Osi2PostfachProperties.ApiConfiguration apiConfiguration;
 
-	public void sendMessage(PostfachNachricht nachricht) {
+	public void sendMessage(PostfachNachricht nachricht, List<Osi2Attachment> attachments) {
 		messageExchangeApi.sendMessage(
-				osi2RequestMapper.mapMailboxId(nachricht),
-				osi2RequestMapper.mapOutSendMessageRequestV2(nachricht)
+				requestMapper.mapMailboxId(nachricht),
+				requestMapper.mapOutSendMessageRequestV2(nachricht, attachments)
 		);
 	}
 
-	public Stream<PostfachNachricht> receiveMessages() {
-		return Optional.ofNullable(receiveMessagesResponse().getMessages())
-				.stream()
-				.flatMap(Collection::stream)
-				.map(this::fetchMessageByGuid);
+	public <T extends AbstractResource> void uploadChunk(FileChunkInfo chunkInfo, T chunkResource) {
+		responseMapper.checkChunkUploadSuccess(quarantineApi.uploadChunk(
+				requestMapper.mapDomainChunkMetaData(chunkInfo),
+				apiConfiguration.getTenant(),
+				apiConfiguration.getNameIdentifier(),
+				chunkResource
+		));
 	}
-	
-	private MessageExchangeReceiveMessagesResponse receiveMessagesResponse() {
-		return Optional.ofNullable(messageExchangeApi.receiveMessages(MAX_NUMBER_RECEIVED_MESSAGES, 0))
-				.orElseThrow(() -> new OsiPostfachException("Expect non empty response!", null));
+
+	public boolean checkUploadSuccessful(final String messageId) throws Osi2UploadException {
+		return responseMapper.isSafe(
+				quarantineApi.getUploadStatus(UUID.fromString(messageId))
+		);
 	}
 
-	PostfachNachricht fetchMessageByGuid(final MessageExchangeReceiveMessage message) {
-		var messageReply = messageExchangeApi.getMessage(message.getGuid());
-		return osi2ResponseMapper.toPostfachNachricht(messageReply);
+	public List<String> fetchPendingMessageIds() {
+		return responseMapper.toMessageIds(
+				messageExchangeApi.receiveMessages(MAX_NUMBER_RECEIVED_MESSAGES, 0)
+		);
+	}
+
+	public Osi2Message fetchMessageById(final String messageId) {
+		var messageReply = messageExchangeApi.getMessage(UUID.fromString(messageId));
+		return responseMapper.toMessage(messageReply);
 	}
 
 	public void deleteMessage(final String messageId) {
 		messageExchangeApi.deleteMessage(UUID.fromString(messageId));
 	}
+
+	public void deleteFileUpload(String fileUploadId) {
+		quarantineApi.deleteUpload(UUID.fromString(fileUploadId));
+	}
+
+	public Resource downloadAttachment(String messageGuid, String attachmentGuid) {
+		return messageExchangeApi.getMessageAttachment(UUID.fromString(messageGuid), UUID.fromString(attachmentGuid));
+	}
 }
diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..5acad335c58b9927e9970f04c4ef2634f22fb9fe
--- /dev/null
+++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtil.java
@@ -0,0 +1,42 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import java.time.Duration;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public class WaitUtil {
+
+	public static boolean waitUntil(BooleanSupplier condition, Duration interval, Duration timeout) {
+		var waitResult = new AtomicBoolean(false);
+		try (var executor = Executors.newSingleThreadScheduledExecutor()) {
+			executor.scheduleAtFixedRate(() -> {
+						if (condition.getAsBoolean()) {
+							waitResult.set(true);
+							executor.shutdown();
+						}
+					}, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
+					.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+		} catch (InterruptedException e) {
+			Thread.currentThread().interrupt();
+			throw new IllegalStateException("[waitUntil] Interrupt!", e.getCause());
+		} catch (TimeoutException | CancellationException e) {
+			// ignore
+		} catch (ExecutionException e) {
+			if (e.getCause() instanceof RuntimeException runtimeException) {
+				throw runtimeException;
+			} else {
+				throw new IllegalStateException("Unexpected exception while waiting for condition!", e.getCause());
+			}
+		}
+		return waitResult.get();
+	}
+}
diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml
index dcbea65d1bba45b8c4882d109a07064f09581a61..caf68438b3c62bc347f1ca72610a84a8e772d00d 100644
--- a/src/main/resources/application-local.yml
+++ b/src/main/resources/application-local.yml
@@ -2,4 +2,10 @@ logging:
   level:
     de.ozgcloud.nachrichten.postfach.osiv2: DEBUG
     org.springframework.http: DEBUG
-    org.springframework.web.client: DEBUG
\ No newline at end of file
+    org.springframework.web.client: DEBUG
+
+grpc:
+  client:
+    file-manager:
+      address: static://127.0.0.1:9090
+      negotiationType: PLAINTEXT
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 065fffe83bf04cd7a435ee3d7a96b347b2f9379e..6b2cd38abf23654bce2740cfcbd7ff1ba9d4f324 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,3 +1,4 @@
 spring:
   jackson:
-    default-property-inclusion: NON_NULL
\ No newline at end of file
+    default-property-inclusion: NON_NULL
+
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/HttpProxyConfig.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/HttpProxyConfig.java
new file mode 100644
index 0000000000000000000000000000000000000000..6e89c6e4772d1ba97a49543ed051afc67a9a1f20
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/HttpProxyConfig.java
@@ -0,0 +1,32 @@
+package de.ozgcloud.nachrichten.postfach.osiv2;
+
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public record HttpProxyConfig(
+		String host,
+		String port
+) {
+
+	public static Optional<HttpProxyConfig> fromEnv() {
+		return Optional.ofNullable(System.getenv("HTTP_PROXY"))
+				.map(HttpProxyConfig::fromEnvVar);
+	}
+
+	private static HttpProxyConfig fromEnvVar(String httpProxyEnvVar) {
+		var matcher = matchProxyRegex(httpProxyEnvVar);
+		return new HttpProxyConfig(
+				matcher.group(1),
+				matcher.group(2)
+		);
+	}
+
+	private static Matcher matchProxyRegex(String text) {
+		var matcher = Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+):([0-9]+)").matcher(text);
+		if (matcher.find()) {
+			return matcher;
+		}
+		throw new IllegalArgumentException("Proxy host and port not found in '%s'".formatted(text));
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java
index 94405d692b164bdf5d1d3b3c51974274a314d39b..550d15b32897a7017dd46e2954b9d11379249078 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java
@@ -1,11 +1,13 @@
 package de.ozgcloud.nachrichten.postfach.osiv2;
 
 import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory.*;
 import static org.assertj.core.api.Assertions.*;
 
-import java.time.OffsetDateTime;
+import java.util.List;
 import java.util.UUID;
+import java.util.function.Function;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
@@ -20,15 +22,23 @@ import org.springframework.test.context.TestPropertySource;
 
 import com.github.tomakehurst.wiremock.WireMockServer;
 import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
+import com.thedeanda.lorem.LoremIpsum;
 
-import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.Osi2AttachmentFileService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2PostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.extension.AttachmentExampleUploadUtil;
 import de.ozgcloud.nachrichten.postfach.osiv2.extension.Jwt;
 import de.ozgcloud.nachrichten.postfach.osiv2.extension.OsiMockServerExtension;
+import de.ozgcloud.nachrichten.postfach.osiv2.extension.VorgangManagerServerExtension;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.JsonUtil;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeSendMessageResponseTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyFilesTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineFileResult;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import lombok.SneakyThrows;
 
 @SpringBootTest(classes = TestApplication.class)
@@ -41,10 +51,13 @@ class OsiPostfachRemoteServiceITCase {
 	@RegisterExtension
 	static final OsiMockServerExtension OSI_MOCK_SERVER_EXTENSION = new OsiMockServerExtension();
 
-	private final PostfachNachricht postfachNachricht = PostfachNachrichtTestFactory.create();
+	@RegisterExtension
+	static final VorgangManagerServerExtension VORGANG_MANAGER_SERVER_EXTENSION = new VorgangManagerServerExtension();
 
 	@Autowired
-	private OsiPostfachRemoteService osiPostfachRemoteService;
+	protected OsiPostfachRemoteService osiPostfachRemoteService;
+	@Autowired
+	protected Osi2AttachmentFileService osi2AttachmentFileService;
 
 	@DynamicPropertySource
 	static void dynamicProperties(DynamicPropertyRegistry registry) {
@@ -53,9 +66,11 @@ class OsiPostfachRemoteServiceITCase {
 		registry.add("ozgcloud.osiv2.auth.client-id", () -> CLIENT_ID);
 		registry.add("ozgcloud.osiv2.api.url", OSI_MOCK_SERVER_EXTENSION::getPostfachFacadeUrl);
 		registry.add("ozgcloud.osiv2.auth.resource", () -> RESOURCE_URN);
+		registry.add("grpc.client." + GRPC_FILE_MANAGER_NAME + ".address", VORGANG_MANAGER_SERVER_EXTENSION::getVorgangManagerAddress);
+		registry.add("grpc.client." + GRPC_FILE_MANAGER_NAME + ".negotiationType", () -> "PLAINTEXT");
 	}
 
-	private WireMockServer postfachFacadeMockServer;
+	protected WireMockServer postfachFacadeMockServer;
 
 	@BeforeEach
 	@SneakyThrows
@@ -67,15 +82,8 @@ class OsiPostfachRemoteServiceITCase {
 	@Test
 	@SneakyThrows
 	void shouldSendRequestWithJwt() {
-		// Stub message send response (MessageExchangeApi::sendMessage)
-		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/MessageExchange/v1/Send/{mailboxId}"))
-				.willReturn(
-						okJsonObj(
-								MessageExchangeSendMessageResponseTestFactory.create()
-										.messageId(UUID.randomUUID())
-						)
-				)
-		);
+		var postfachNachricht = PostfachNachrichtTestFactory.create();
+		mockSendResponse();
 
 		osiPostfachRemoteService.sendMessage(postfachNachricht);
 
@@ -87,6 +95,140 @@ class OsiPostfachRemoteServiceITCase {
 		assertThat(jwt.body().read("$.aud", String.class)).isEqualTo(RESOURCE_URN);
 	}
 
+	@DisplayName("should send message with attachment")
+	@Test
+	void shouldSendMessageWithAttachment() {
+		var textFileId = AttachmentExampleUploadUtil.uploadTextFile(osi2AttachmentFileService);
+		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/Quarantine/v1/Upload/Chunked"))
+				.willReturn(okJsonObj(QuarantineFileResult.builder()
+						.success(true)
+						.build()))
+		);
+		postfachFacadeMockServer.stubFor(get(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+				.willReturn(okJsonObj(QuarantineStatus.SAFE))
+		);
+		var postfachNachrichtWithAttachment = PostfachNachrichtTestFactory.createBuilder()
+				.attachments(List.of(textFileId))
+				.build();
+		mockSendResponse();
+
+		osiPostfachRemoteService.sendMessage(postfachNachrichtWithAttachment);
+
+		var requests = postfachFacadeMockServer.findAll(
+				postRequestedFor(urlPathTemplate("/Quarantine/v1/Upload/Chunked")));
+		assertThat(requests).hasSize(2);
+		Function<Integer, byte[]> requestBodyBytes = requestIndex -> requests.get(requestIndex).getPart("file").getBody().asBytes();
+		// should send file in one chunk
+		assertThat(requestBodyBytes.apply(0)).isEqualTo(AttachmentExampleUploadUtil.EXAMPLE_TEXT_DATA);
+		// should send empty chunk to complete transfer
+		assertThat(requestBodyBytes.apply(1)).isEmpty();
+		postfachFacadeMockServer.verify(
+				exactly(1),
+				getRequestedFor(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+		);
+		postfachFacadeMockServer.verify(
+				exactly(1),
+				postRequestedFor(urlPathTemplate("/MessageExchange/v1/Send/{mailboxId}"))
+		);
+	}
+
+	@DisplayName("should throw postfach exception with connection error code")
+	@Test
+	void shouldThrowPostfachExceptionWithConnectionErrorCode() {
+		postfachFacadeMockServer.stop();
+
+		var postfachNachricht = PostfachNachrichtTestFactory.create();
+
+		assertThatThrownBy(() -> osiPostfachRemoteService.sendMessage(postfachNachricht))
+				.isInstanceOf(Osi2PostfachException.class)
+				.hasFieldOrPropertyWithValue("messageCode", PostfachMessageCode.SERVER_CONNECTION_FAILED_MESSAGE_CODE);
+	}
+
+	@DisplayName("should throw postfach exception with unknown postfach id error code")
+	@Test
+	void shouldThrowPostfachExceptionWithUnknownPostfachIdErrorCode() {
+		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/MessageExchange/v1/Send/{mailboxId}"))
+				.willReturn(notFound())
+		);
+		var postfachNachricht = PostfachNachrichtTestFactory.create();
+
+		assertThatThrownBy(() -> osiPostfachRemoteService.sendMessage(postfachNachricht))
+				.isInstanceOf(Osi2PostfachException.class)
+				.hasFieldOrPropertyWithValue("messageCode", PostfachMessageCode.SEND_FAILED_UNKNOWN_POSTFACH_ID_MESSAGE_CODE);
+	}
+
+	@DisplayName("should delete attachments on upload exception")
+	@Test
+	void shouldDeleteAttachmentsOnUploadException() {
+		var textFileId = AttachmentExampleUploadUtil.uploadTextFile(osi2AttachmentFileService);
+		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/Quarantine/v1/Upload/Chunked"))
+				.willReturn(okJsonObj(QuarantineFileResult.builder()
+						.success(false)
+						.error("Upload failure")
+						.build()))
+		);
+		postfachFacadeMockServer.stubFor(delete(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+				.willReturn(ok())
+		);
+		var postfachNachrichtWithAttachment = PostfachNachrichtTestFactory.createBuilder()
+				.attachments(List.of(textFileId))
+				.build();
+
+		try {
+			osiPostfachRemoteService.sendMessage(postfachNachrichtWithAttachment);
+		} catch (Osi2PostfachException e) {
+			// ignore
+		}
+
+		postfachFacadeMockServer.verify(
+				exactly(1),
+				deleteRequestedFor(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+		);
+	}
+
+	@DisplayName("should delete attachments on scan exception")
+	@Test
+	void shouldDeleteAttachmentsOnScanException() {
+		var textFileId = AttachmentExampleUploadUtil.uploadTextFile(osi2AttachmentFileService);
+		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/Quarantine/v1/Upload/Chunked"))
+				.willReturn(okJsonObj(QuarantineFileResult.builder()
+						.success(true)
+						.build()))
+		);
+		postfachFacadeMockServer.stubFor(get(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+				.willReturn(okJsonObj(QuarantineStatus.UNSAFE))
+		);
+		postfachFacadeMockServer.stubFor(delete(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+				.willReturn(ok())
+		);
+		var postfachNachrichtWithAttachment = PostfachNachrichtTestFactory.createBuilder()
+				.attachments(List.of(textFileId))
+				.build();
+
+		try {
+			osiPostfachRemoteService.sendMessage(postfachNachrichtWithAttachment);
+		} catch (Osi2PostfachException e) {
+			// ignore
+		}
+
+		postfachFacadeMockServer.verify(
+				exactly(1),
+				deleteRequestedFor(urlPathTemplate("/Quarantine/v1/Upload/{messageGuid}"))
+		);
+	}
+
+	private void mockSendResponse() {
+		// Stub message send response (MessageExchangeApi::sendMessage)
+		postfachFacadeMockServer.stubFor(post(urlPathTemplate("/MessageExchange/v1/Send/{mailboxId}"))
+				.willReturn(
+						okJsonObj(
+								MessageExchangeSendMessageResponseTestFactory.create()
+										.messageId(UUID.randomUUID())
+						)
+				)
+		);
+	}
+
 	@DisplayName("should receive one messages")
 	@Test
 	@SneakyThrows
@@ -96,6 +238,12 @@ class OsiPostfachRemoteServiceITCase {
 		var messageList = osiPostfachRemoteService.getAllMessages().toList();
 
 		assertThat(messageList).hasSize(1);
+		var firstMessage = messageList.getFirst();
+		assertThat(firstMessage.getAttachments()).hasSize(1);
+		var fileMetadata = osi2AttachmentFileService.getFileMetadataOfIds(firstMessage.getAttachments());
+		var firstAttachment = fileMetadata.getFirst();
+		assertThat(firstAttachment.getName()).isNotBlank();
+		assertThat(firstAttachment.getContentType()).isEqualTo("text/plain");
 	}
 
 	@DisplayName("should receive two messages")
@@ -109,23 +257,37 @@ class OsiPostfachRemoteServiceITCase {
 		assertThat(messageList).hasSize(2);
 	}
 
-	private void mockPostfachMessageAndResponse(final String... uuids) {
+	private void mockPostfachMessageAndResponse(final String... messageGuids) {
 		// Stub message listing response (MessageExchangeApi::receiveMessages)
 		postfachFacadeMockServer.stubFor(get(urlPathEqualTo("/MessageExchange/v1/Receive"))
 				.withQueryParam("take", equalTo("100"))
 				.withQueryParam("skip", equalTo("0"))
-				.willReturn(okJsonObj(MessageExchangeReceiveMessagesResponseTestFactory.create(uuids)))
+				.willReturn(okJsonObj(MessageExchangeReceiveMessagesResponseTestFactory.create(messageGuids)))
 		);
-		for (String uuid : uuids) {
+		for (String messageGuid : messageGuids) {
+
+			var attachmentText = LoremIpsum.getInstance().getParagraphs(10, 20);
+			var attachment = V1ReplyFilesTestFactory.createBuilder()
+					.guid(UUID.randomUUID())
+					.name(LoremIpsum.getInstance().getName() + ".txt")
+					.build();
 			// Stub individual response for message (MessageExchangeApi::getMessage)
 			postfachFacadeMockServer.stubFor(get(urlPathTemplate("/MessageExchange/v1/Receive/{messageId}"))
-					.withPathParam("messageId", equalTo(uuid))
-					.willReturn(okJsonObj(
-							V1ReplyMessageTestFactory.create()
-									.messageBox(UUID.fromString(uuid))
-									.responseTime(OffsetDateTime.now())
+					.withPathParam("messageId", equalTo(messageGuid))
+					.willReturn(okJsonObj(V1ReplyMessageTestFactory.create()
+							.guid(UUID.fromString(messageGuid))
+							.files(List.of(attachment))
 					))
 			);
+			// Stub attachment response (MessageExchangeApi::getMessageAttachment)
+			postfachFacadeMockServer.stubFor(get(urlPathTemplate("/MessageExchange/v1/Receive/{messageId}/Attachment/{attachmentId}"))
+					.withPathParam("messageId", equalTo(messageGuid))
+					.withPathParam("attachmentId", equalTo(attachment.getGuid().toString()))
+					.willReturn(ok()
+							.withHeader("Content-Type", "application/octet-stream")
+							.withBody(attachmentText.getBytes())
+					)
+			);
 		}
 	}
 
@@ -151,5 +313,4 @@ class OsiPostfachRemoteServiceITCase {
 						.withPathParam("messageId", equalTo(messageId))
 		);
 	}
-
 }
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java
index 4bcdb775d291573e8a3ec8f1d503726ff31b878b..9662f9c2a26bdbb63713f4c9b43d36dcc506e392 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java
@@ -1,21 +1,27 @@
 package de.ozgcloud.nachrichten.postfach.osiv2;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties.*;
 import static org.assertj.core.api.Assertions.*;
 
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import java.util.Arrays;
 
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.junit.jupiter.api.extension.RegisterExtension;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.test.context.ActiveProfiles;
 import org.springframework.test.context.DynamicPropertyRegistry;
 import org.springframework.test.context.DynamicPropertySource;
 
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
-import de.ozgcloud.nachrichten.postfach.osiv2.factory.DummyStringBasedIdentifier;
+import de.ozgcloud.nachrichten.postfach.StringBasedIdentifier;
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.Osi2AttachmentFileService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2PostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.extension.AttachmentExampleUploadUtil;
+import de.ozgcloud.nachrichten.postfach.osiv2.extension.VorgangManagerServerExtension;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachAddressTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
 import lombok.SneakyThrows;
@@ -28,36 +34,41 @@ class OsiPostfachRemoteServiceRemoteITCase {
 	@Autowired
 	private OsiPostfachRemoteService osiPostfachRemoteService;
 
+	@RegisterExtension
+	static final VorgangManagerServerExtension VORGANG_MANAGER_SERVER_EXTENSION = new VorgangManagerServerExtension();
+
 	@DynamicPropertySource
 	static void dynamicProperties(DynamicPropertyRegistry registry) {
 		registry.add(
 				"ozgcloud.osiv2.auth.client-secret",
 				() -> System.getenv("SH_STAGE_CLIENT_SECRET")
 		);
+		registry.add(
+				"ozgcloud.osiv2.proxy.enabled",
+				() -> HttpProxyConfig.fromEnv().map(v -> "true").orElse("false")
+		);
 		registry.add(
 				"ozgcloud.osiv2.proxy.host",
-				() -> matchProxyRegex(System.getenv("HTTP_PROXY")).group(1)
+				() -> HttpProxyConfig.fromEnv().map(HttpProxyConfig::host).orElse("")
 		);
 		registry.add(
 				"ozgcloud.osiv2.proxy.port",
-				() -> matchProxyRegex(System.getenv("HTTP_PROXY")).group(2)
+				() -> HttpProxyConfig.fromEnv().map(HttpProxyConfig::port).orElse("")
 		);
+		registry.add("grpc.client." + GRPC_FILE_MANAGER_NAME + ".address", VORGANG_MANAGER_SERVER_EXTENSION::getVorgangManagerAddress);
+		registry.add("grpc.client." + GRPC_FILE_MANAGER_NAME + ".negotiationType", () -> "PLAINTEXT");
 	}
 
-	private static Matcher matchProxyRegex(String text) {
-		var matcher = Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+):([0-9]+)").matcher(text);
-		if (matcher.find()) {
-			return matcher;
-		}
-		throw new IllegalArgumentException("Proxy host and port not found in '%s'".formatted(text));
-	}
+	@Autowired
+	protected Osi2AttachmentFileService osi2AttachmentFileService;
 
-	private PostfachNachricht createNachricht() {
+	private PostfachNachricht createNachricht(String... attachmentIds) {
 		return PostfachNachrichtTestFactory.createBuilder()
 				.replyOption(PostfachNachricht.ReplyOption.POSSIBLE)
+				.attachments(Arrays.stream(attachmentIds).toList())
 				.postfachAddress(PostfachAddressTestFactory.createBuilder()
-						.identifier(DummyStringBasedIdentifier.builder()
-								.mailboxId("49b5a7e2-5e60-4baf-8ccf-1f5b94b570f3")
+						.identifier(StringBasedIdentifier.builder()
+								.postfachId("49b5a7e2-5e60-4baf-8ccf-1f5b94b570f3")
 								.build())
 						.build())
 				.build();
@@ -73,6 +84,26 @@ class OsiPostfachRemoteServiceRemoteITCase {
 				.doesNotThrowAnyException();
 	}
 
+	@DisplayName("should not fail sending message with attachment")
+	@Test
+	void shouldNotFailSendingMessageWithAttachment() {
+		var textFileId = AttachmentExampleUploadUtil.uploadTextFile(osi2AttachmentFileService);
+		var nachricht = createNachricht(textFileId);
+
+		assertThatCode(() -> osiPostfachRemoteService.sendMessage(nachricht))
+				.doesNotThrowAnyException();
+	}
+
+	@DisplayName("should throw postfach exception with unknown postfach id error code")
+	@Test
+	void shouldThrowPostfachExceptionWithUnknownPostfachIdErrorCode() {
+		var nachrichtWithUnknownPostfachId = PostfachNachrichtTestFactory.create();
+
+		assertThatThrownBy(() -> osiPostfachRemoteService.sendMessage(nachrichtWithUnknownPostfachId))
+				.isInstanceOf(Osi2PostfachException.class)
+				.hasFieldOrPropertyWithValue("messageCode", PostfachMessageCode.SEND_FAILED_UNKNOWN_POSTFACH_ID_MESSAGE_CODE);
+	}
+
 	@DisplayName("should receive messages")
 	@Test
 	void shouldReceiveMessages() {
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java
index 685c36f61dd8d1d44afc185da5bf94bf3eb4ee67..5cb953eb50afe7831f1096d7912b8e3fe5f8fe70 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceTest.java
@@ -1,9 +1,9 @@
 package de.ozgcloud.nachrichten.postfach.osiv2;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory.*;
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
-import java.util.UUID;
 import java.util.stream.Stream;
 
 import org.junit.jupiter.api.DisplayName;
@@ -13,18 +13,23 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Spy;
 
+import de.ozgcloud.nachrichten.postfach.PostfachMessageCode;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2ExceptionHandler;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
-import de.ozgcloud.nachrichten.postfach.osiv2.transfer.PostfachApiFacadeService;
+import de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2PostfachService;
 
 class OsiPostfachRemoteServiceTest {
 
-	@Spy
 	@InjectMocks
+	@Spy
 	private OsiPostfachRemoteService osiPostfachRemoteService;
 
 	@Mock
-	private PostfachApiFacadeService postfachApiFacadeService;
+	private Osi2PostfachService osi2PostfachService;
+
+	@Mock
+	private Osi2ExceptionHandler exceptionHandler;
 
 	private final PostfachNachricht nachricht1 = PostfachNachrichtTestFactory.createBuilder()
 			.subject("Nachricht 1")
@@ -33,26 +38,32 @@ class OsiPostfachRemoteServiceTest {
 			.subject("Nachricht 2")
 			.build();
 
+	private final PostfachMessageCode postfachMessageCode = PostfachMessageCode.PROCESS_FAILED_MESSAGE_CODE;
+
 	@DisplayName("send message")
 	@Nested
-	class TestSendMessage {
+	class TestSendOsi2Message {
 
 		@DisplayName("should call send message")
 		@Test
 		void shouldCallSendMessage() {
-			postfachApiFacadeService.sendMessage(nachricht1);
+			osi2PostfachService.sendMessage(nachricht1);
 
-			verify(postfachApiFacadeService).sendMessage(nachricht1);
+			verify(osi2PostfachService).sendMessage(nachricht1);
 		}
 
 		@DisplayName("should throw osi postfach exception on runtime exception")
 		@Test
 		void shouldThrowOsiPostfachExceptionOnRuntimeException() {
-			doThrow(new RuntimeException()).when(postfachApiFacadeService).sendMessage(nachricht1);
+			var runtimeException = createRuntimeException();
+			doThrow(runtimeException).when(osi2PostfachService).sendMessage(nachricht1);
+			when(exceptionHandler.deriveMessageCode(runtimeException)).thenReturn(postfachMessageCode);
 
 			assertThatThrownBy(() -> osiPostfachRemoteService.sendMessage(nachricht1))
-					.isInstanceOf(OsiPostfachException.class);
+					.hasFieldOrPropertyWithValue("messageCode", postfachMessageCode)
+					.hasCause(runtimeException);
 		}
+
 	}
 
 	@DisplayName("get all messages")
@@ -62,32 +73,48 @@ class OsiPostfachRemoteServiceTest {
 		@DisplayName("should return postfach messages")
 		@Test
 		void shouldReturnPostfachMessages() {
-			when(postfachApiFacadeService.receiveMessages()).thenReturn(Stream.of(nachricht1, nachricht2));
+			when(osi2PostfachService.receiveMessages()).thenReturn(Stream.of(nachricht1, nachricht2));
 
 			var result = osiPostfachRemoteService.getAllMessages();
 
 			assertThat(result).containsExactly(nachricht1, nachricht2);
 		}
+
+		@DisplayName("should throw osi postfach exception on runtime exception")
+		@Test
+		void shouldThrowOsiPostfachExceptionOnRuntimeException() {
+			var runtimeException = createRuntimeException();
+			doThrow(runtimeException).when(osi2PostfachService).receiveMessages();
+			when(exceptionHandler.deriveMessageCode(runtimeException)).thenReturn(postfachMessageCode);
+
+			assertThatThrownBy(() -> osiPostfachRemoteService.getAllMessages())
+					.hasFieldOrPropertyWithValue("messageCode", postfachMessageCode)
+					.hasCause(runtimeException);
+		}
 	}
 
 	@DisplayName("delete message")
 	@Nested
-	class TestDeleteMessage {
+	class TestDeleteOsi2Message {
 
 		@DisplayName("should call deleteMessage")
 		@Test
 		void shouldCallDeleteMessage() {
-			postfachApiFacadeService.deleteMessage(UUID.randomUUID().toString());
+			osiPostfachRemoteService.deleteMessage(MESSAGE_ID_1);
 
-			verify(postfachApiFacadeService).deleteMessage(any());
+			verify(osi2PostfachService).deleteMessage(MESSAGE_ID_1);
 		}
 
 		@DisplayName("should throw osi postfach exception on runtime exception")
 		@Test
 		void shouldThrowOsiPostfachExceptionOnRuntimeException() {
-			doThrow(new RuntimeException()).when(postfachApiFacadeService).deleteMessage(UUID.randomUUID().toString());
+			var runtimeException = createRuntimeException();
+			doThrow(runtimeException).when(osi2PostfachService).deleteMessage(any());
+			when(exceptionHandler.deriveMessageCode(runtimeException)).thenReturn(postfachMessageCode);
 
-			assertThatThrownBy(() -> osiPostfachRemoteService.deleteMessage(anyString())).isInstanceOf(OsiPostfachException.class);
+			assertThatThrownBy(() -> osiPostfachRemoteService.deleteMessage(MESSAGE_ID_1))
+					.hasFieldOrPropertyWithValue("messageCode", postfachMessageCode)
+					.hasCause(runtimeException);
 		}
 	}
 
@@ -114,4 +141,9 @@ class OsiPostfachRemoteServiceTest {
 			assertThat(result).isTrue();
 		}
 	}
+
+	private RuntimeException createRuntimeException() {
+		return new RuntimeException();
+	}
+
 }
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/TestApplication.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/TestApplication.java
index afa904e215a5a977eaffd542aadc82e26db44004..7a6df869213fdc6c4a5cbcef3edcc56693b0c7cb 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/TestApplication.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/TestApplication.java
@@ -1,9 +1,44 @@
 package de.ozgcloud.nachrichten.postfach.osiv2;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties.*;
+
+import org.mapstruct.factory.Mappers;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+
+import de.ozgcloud.apilib.common.callcontext.CallContext;
+import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextProvider;
+import de.ozgcloud.apilib.file.OzgCloudFileService;
+import de.ozgcloud.apilib.file.grpc.GrpcOzgCloudFileService;
+import de.ozgcloud.apilib.file.grpc.OzgCloudFileMapper;
+import de.ozgcloud.apilib.vorgang.OzgCloudUserIdMapper;
+import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc;
+import net.devh.boot.grpc.client.inject.GrpcClient;
 
 @SpringBootApplication
 @AutoConfiguration
 public class TestApplication {
+
+	// From de.ozgcloud.nachrichten.common.grpc.NachrichtenCallContextAttachingInterceptor
+	public static final String NACHRICHTEN_MANAGER_CLIENT_NAME = "OzgCloud_NachrichtenManager";
+	public static final String NACHRICHTEN_MANAGER_SENDER_USER_ID = "system-nachrichten_manager-sender";
+
+	@GrpcClient(GRPC_FILE_MANAGER_NAME)
+	private BinaryFileServiceGrpc.BinaryFileServiceBlockingStub fileServiceBlockingStub;
+	@GrpcClient(GRPC_FILE_MANAGER_NAME)
+	private BinaryFileServiceGrpc.BinaryFileServiceStub fileServiceAsyncServiceStub;
+
+	@Bean
+	OzgCloudCallContextProvider ozgCloudCallContextProvider() {
+		var userIdMapper = Mappers.getMapper(OzgCloudUserIdMapper.class);
+		return () -> CallContext.builder().clientName(NACHRICHTEN_MANAGER_CLIENT_NAME)
+				.userId(userIdMapper.toUserId(NACHRICHTEN_MANAGER_SENDER_USER_ID)).build();
+	}
+
+	@Bean(OZG_CLOUD_FILE_SERVICE_NAME)
+	OzgCloudFileService grpcOzgCloudFileService(OzgCloudCallContextProvider contextProvider) {
+		return new GrpcOzgCloudFileService(fileServiceBlockingStub, fileServiceAsyncServiceStub, contextProvider,
+				Mappers.getMapper(OzgCloudFileMapper.class));
+	}
 }
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/ConcatInputStreamTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/ConcatInputStreamTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9340fe2ab0ae140e324cf7b1ccff1c2b60b65376
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/ConcatInputStreamTest.java
@@ -0,0 +1,78 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.ByteArrayInputStream;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import lombok.SneakyThrows;
+
+class ConcatInputStreamTest {
+
+	@DisplayName("should concatenate input streams")
+	@Test
+	@SneakyThrows
+	void shouldConcatenateInputStreams() {
+		var concatStream = createExampleConcatInputStream();
+
+		var result = IOUtils.toByteArray(concatStream);
+
+		assertEquals("123456789", new String(result));
+	}
+
+	@DisplayName("should read one")
+	@Test
+	@SneakyThrows
+	void shouldReadOne() {
+		var concatStream = createExampleConcatInputStream();
+
+		var result = concatStream.read();
+
+		assertThat(result).isEqualTo('1');
+	}
+
+	@DisplayName("should write to buffer with offset")
+	@Test
+	@SneakyThrows
+	void shouldWriteToBufferWithOffset() {
+		var buffer = new byte[10];
+		var concatStream = createExampleConcatInputStream();
+
+		var result = concatStream.read(buffer, 2, 2);
+		assertThat(result).isEqualTo(2);
+		assertThat(buffer).containsExactly(0, 0, '1', '2', 0, 0, 0, 0, 0, 0);
+
+		result = concatStream.read(buffer, 3, 2);
+		assertThat(result).isEqualTo(2);
+		assertThat(buffer).containsExactly(0, 0, '1', '3', '4', 0, 0, 0, 0, 0);
+
+		result = concatStream.read(buffer, 4, 2);
+		assertThat(result).isEqualTo(2);
+		assertThat(buffer).containsExactly(0, 0, '1', '3', '5', '6', 0, 0, 0, 0);
+
+		result = concatStream.read(buffer, 5, 2);
+		assertThat(result).isEqualTo(2);
+		assertThat(buffer).containsExactly(0, 0, '1', '3', '5', '7', '8', 0, 0, 0);
+
+		result = concatStream.read(buffer, 6, 2);
+		assertThat(result).isEqualTo(1);
+		assertThat(buffer).containsExactly(0, 0, '1', '3', '5', '7', '9', 0, 0, 0);
+
+		result = concatStream.read(buffer, 7, 2);
+		assertThat(result).isEqualTo(-1);
+		assertThat(buffer).containsExactly(0, 0, '1', '3', '5', '7', '9', 0, 0, 0);
+	}
+
+	private ConcatInputStream createExampleConcatInputStream() {
+		var streams = Stream.of("1234", "567", "", "89")
+				.map(s -> new ByteArrayInputStream(s.getBytes()))
+				.toList();
+		return new ConcatInputStream(streams.iterator());
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/LimitedInputStreamTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/LimitedInputStreamTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..880c800901bce17d30e5ab2cd38a21647b2e5206
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/LimitedInputStreamTest.java
@@ -0,0 +1,54 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.ByteArrayInputStream;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import lombok.SneakyThrows;
+
+class LimitedInputStreamTest {
+
+	@DisplayName("should limit input stream")
+	@Test
+	@SneakyThrows
+	void shouldLimitInputStream() {
+		var inputStream = createExampleLimitedInputStream();
+
+		var result = IOUtils.toByteArray(inputStream);
+
+		assertEquals("12345", new String(result));
+	}
+
+	@DisplayName("should read one")
+	@Test
+	@SneakyThrows
+	void shouldReadOne() {
+		var inputStream = createExampleLimitedInputStream();
+
+		var result = inputStream.read();
+
+		assertEquals('1', result);
+	}
+
+	@DisplayName("should keep parent stream usable")
+	@Test
+	@SneakyThrows
+	void shouldKeepParentStreamUsable() {
+		var parentStream = new ByteArrayInputStream("123456789".getBytes());
+		var inputStream = new LimitedInputStream(parentStream, 5);
+		var inputStream2 = new LimitedInputStream(parentStream, 5);
+		IOUtils.toByteArray(inputStream);
+
+		var result = IOUtils.toByteArray(inputStream2);
+
+		assertEquals("6789", new String(result));
+	}
+
+	private LimitedInputStream createExampleLimitedInputStream() {
+		return new LimitedInputStream(new ByteArrayInputStream("123456789".getBytes()), 5);
+	}
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileMapperTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileMapperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a608d356b56dda5fe98074aa19badbc6aa486746
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileMapperTest.java
@@ -0,0 +1,108 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory.*;
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.apilib.file.OzgCloudUploadFile;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.AttachmentFileTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.GrpcOzgFileTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import de.ozgcloud.vorgang.grpc.file.GrpcOzgFile;
+
+class Osi2AttachmentFileMapperTest {
+
+	private final Osi2AttachmentFileMapper mapper = Mappers.getMapper(Osi2AttachmentFileMapper.class);
+
+	@DisplayName("map to upload file metadata")
+	@Nested
+	class TestMapToUploadFileMetadata {
+
+		private final GrpcOzgFile grpcOzgFile = GrpcOzgFileTestFactory.create();
+
+		@DisplayName("should map id")
+		@Test
+		void shouldMapId() {
+			var result = doMapping();
+
+			assertThat(result.getId()).hasToString(grpcOzgFile.getId());
+		}
+
+		@DisplayName("should map name")
+		@Test
+		void shouldMapName() {
+			var result = doMapping();
+
+			assertThat(result.getName()).isEqualTo(grpcOzgFile.getName());
+		}
+
+		@DisplayName("should map contentType")
+		@Test
+		void shouldMapContentType() {
+			var result = doMapping();
+
+			assertThat(result.getContentType()).isEqualTo(grpcOzgFile.getContentType());
+		}
+
+		@DisplayName("should map size")
+		@Test
+		void shouldMapSize() {
+			var result = doMapping();
+
+			assertThat(result.getSize()).isEqualTo(grpcOzgFile.getSize());
+		}
+
+		private OzgCloudFile doMapping() {
+			return mapper.mapToUploadFileMetadata(grpcOzgFile);
+		}
+	}
+
+	@DisplayName("to ozg cloud upload file")
+	@Nested
+	class TestToOzgCloudUploadFile {
+		private final AttachmentFile attachmentFile = AttachmentFileTestFactory.create();
+
+		@DisplayName("should map fileName")
+		@Test
+		void shouldMapFileName() {
+			var result = doMapping();
+
+			assertThat(result.getFileName()).isEqualTo(UPLOAD_NAME);
+		}
+
+		@DisplayName("should map contentType")
+		@Test
+		void shouldMapContentType() {
+			var result = doMapping();
+
+			assertThat(result.getContentType()).isEqualTo(UPLOAD_CONTENT_TYPE);
+		}
+
+		@DisplayName("should map fieldName")
+		@Test
+		void shouldMapFieldName() {
+			var result = doMapping();
+
+			assertThat(result.getFieldName()).isEqualTo(Osi2AttachmentFileMapper.ATTACHMENT_FIELD_NAME);
+		}
+
+		@DisplayName("should map vorgangId")
+		@Test
+		void shouldMapVorgangId() {
+			var result = doMapping();
+
+			assertThat(result.getVorgangId()).isEqualTo(VORGANG_ID);
+		}
+
+		private OzgCloudUploadFile doMapping() {
+			return mapper.toOzgCloudUploadFile(attachmentFile);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..892e9c22928b505e00ec601a89c010b312168be8
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2AttachmentFileServiceTest.java
@@ -0,0 +1,250 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.GrpcOzgFileTestFactory.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+import com.google.protobuf.ByteString;
+
+import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextAttachingInterceptor;
+import de.ozgcloud.apilib.common.callcontext.OzgCloudCallContextProvider;
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.apilib.file.OzgCloudFileId;
+import de.ozgcloud.apilib.file.OzgCloudFileService;
+import de.ozgcloud.apilib.file.OzgCloudFileTestFactory;
+import de.ozgcloud.apilib.file.OzgCloudUploadFile;
+import de.ozgcloud.apilib.file.OzgCloudUploadFileTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import de.ozgcloud.vorgang.grpc.binaryFile.BinaryFileServiceGrpc;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcBinaryFilesRequest;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcFindFilesResponse;
+import de.ozgcloud.vorgang.grpc.binaryFile.GrpcGetBinaryFileDataResponse;
+import de.ozgcloud.vorgang.grpc.file.GrpcOzgFile;
+import lombok.SneakyThrows;
+
+class Osi2AttachmentFileServiceTest {
+
+	@Spy
+	@InjectMocks
+	private Osi2AttachmentFileService service;
+
+	@Mock
+	private Osi2AttachmentFileMapper attachmentFileMapper;
+
+	@Mock
+	private OzgCloudFileService ozgCloudFileService;
+
+	@Mock
+	private OzgCloudCallContextProvider ozgCloudCallContextProvider;
+
+	@Mock
+	private BinaryFileServiceGrpc.BinaryFileServiceBlockingStub binaryFileServiceStubWithoutInterceptor;
+
+	@Mock
+	private BinaryFileServiceGrpc.BinaryFileServiceBlockingStub binaryFileServiceStub;
+
+	@Captor
+	private ArgumentCaptor<OzgCloudCallContextAttachingInterceptor> interceptorCaptor;
+
+	@BeforeEach
+	void setup() {
+		when(binaryFileServiceStubWithoutInterceptor.withInterceptors(interceptorCaptor.capture())).thenReturn(binaryFileServiceStub);
+		service.setBinaryFileServiceStub(binaryFileServiceStubWithoutInterceptor);
+	}
+
+	@DisplayName("should set ozg cloud call context provider")
+	@Test
+	void shouldSetOzgCloudCallContextProvider() {
+		assertThat(interceptorCaptor.getValue())
+				.extracting("callContextProvider")
+				.isEqualTo(ozgCloudCallContextProvider);
+	}
+
+
+	@DisplayName("get file metadata of ids")
+	@Nested
+	class TestGetFileMetadataOfIds {
+
+		@Mock
+		private GrpcFindFilesResponse grpcFindFilesResponse;
+
+		@Mock
+		private GrpcBinaryFilesRequest grpcBinaryFilesRequest;
+
+		@Mock
+		private GrpcOzgFile grpcOzgFile;
+
+		private final OzgCloudFile ozgCloudFile = OzgCloudFileTestFactory.create();
+
+		@BeforeEach
+		void mock() {
+			doReturn(grpcBinaryFilesRequest).when(service).createRequestWithFileIds(any());
+			when(binaryFileServiceStub.findBinaryFilesMetaData(any())).thenReturn(grpcFindFilesResponse);
+			when(grpcFindFilesResponse.getFileList()).thenReturn(List.of(grpcOzgFile));
+			when(attachmentFileMapper.mapToUploadFileMetadata(any())).thenReturn(ozgCloudFile);
+		}
+
+		@DisplayName("should call createRequestWithFileIds")
+		@Test
+		void shouldCallCreateRequestWithFileIds() {
+			getFileMetadataOfIds();
+
+			verify(service).createRequestWithFileIds(List.of(FILE_ID));
+		}
+
+		@DisplayName("should call findBinaryFilesMetaData")
+		@Test
+		void shouldCallFindBinaryFilesMetaData() {
+			getFileMetadataOfIds();
+
+			verify(binaryFileServiceStub).findBinaryFilesMetaData(grpcBinaryFilesRequest);
+		}
+
+		@DisplayName("should call map to upload file metadata")
+		@Test
+		void shouldCallMapToUploadFileMetadata() {
+			getFileMetadataOfIds();
+
+			verify(attachmentFileMapper).mapToUploadFileMetadata(grpcOzgFile);
+		}
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = getFileMetadataOfIds();
+
+			assertThat(result).containsExactly(ozgCloudFile);
+		}
+
+		private List<OzgCloudFile> getFileMetadataOfIds() {
+			return service.getFileMetadataOfIds(List.of(FILE_ID));
+		}
+
+	}
+
+	@DisplayName("create request with file ids")
+	@Nested
+	class TestCreateRequestWithFileIds {
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = service.createRequestWithFileIds(List.of(FILE_ID));
+
+			assertThat(result.getFileIdList()).containsExactly(FILE_ID);
+		}
+
+	}
+
+	@DisplayName("upload file and return id")
+	@Nested
+	class TestUploadFileAndReturnId {
+		@Mock
+		private InputStream inputStream;
+
+		@Mock
+		private AttachmentFile attachmentFile;
+
+		private final OzgCloudUploadFile ozgCloudUploadFile = OzgCloudUploadFileTestFactory.create();
+
+		@BeforeEach
+		void mock() {
+			when(attachmentFileMapper.toOzgCloudUploadFile(any())).thenReturn(ozgCloudUploadFile);
+			when(ozgCloudFileService.uploadFile(any(), any())).thenReturn(new OzgCloudFileId(FILE_ID));
+		}
+
+		@DisplayName("should call toOzgCloudUploadFile")
+		@Test
+		void shouldCallToOzgCloudUploadFile() {
+			uploadFileAndReturnId();
+
+			verify(attachmentFileMapper).toOzgCloudUploadFile(attachmentFile);
+		}
+
+		@DisplayName("should call uploadFile")
+		@Test
+		void shouldCallUploadFile() {
+			uploadFileAndReturnId();
+
+			verify(ozgCloudFileService).uploadFile(any(), any());
+		}
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = uploadFileAndReturnId();
+
+			assertThat(result).isEqualTo(FILE_ID);
+		}
+
+		private String uploadFileAndReturnId() {
+			return service.uploadFileAndReturnId(attachmentFile, inputStream);
+		}
+	}
+
+	@DisplayName("download file content")
+	@Nested
+	class TestDownloadFileContent {
+
+		@Mock
+		private GrpcGetBinaryFileDataResponse response1;
+
+		@Mock
+		private ByteString chunkContent1;
+
+		@Mock
+		private GrpcGetBinaryFileDataResponse response2;
+
+		@Mock
+		private ByteString chunkContent2;
+
+		@BeforeEach
+		void mock() {
+			when(binaryFileServiceStub.getBinaryFileContent(any())).thenReturn(List.of(response1, response2).iterator());
+			when(response1.getFileContent()).thenReturn(chunkContent1);
+			when(response2.getFileContent()).thenReturn(chunkContent2);
+			when(chunkContent1.newInput()).thenReturn(new ByteArrayInputStream("abc".getBytes()));
+			when(chunkContent2.newInput()).thenReturn(new ByteArrayInputStream("def".getBytes()));
+		}
+
+
+		@DisplayName("should return")
+		@Test
+		@SneakyThrows
+		void shouldReturn() {
+			var result = service.downloadFileContent(FILE_ID);
+
+			var concatBytes = IOUtils.toByteArray(result);
+			assertThat(new String(concatBytes)).isEqualTo("abcdef");
+		}
+	}
+
+	@DisplayName("create request with file id")
+	@Nested
+	class TestCreateRequestWithFileId {
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = service.createRequestWithFileId(FILE_ID);
+
+			assertThat(result.getFileId()).isEqualTo(FILE_ID);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2PersistAttachmentServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2PersistAttachmentServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..2fcf40037503cf91d20e4ce5206c5c0518e6d72d
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/attachment/Osi2PersistAttachmentServiceTest.java
@@ -0,0 +1,213 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.attachment;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.GrpcOzgFileTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.springframework.core.io.Resource;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.AttachmentFileTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.AttachmentInfoTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2MessageTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+import de.ozgcloud.nachrichten.postfach.osiv2.transfer.PostfachApiFacadeService;
+import lombok.SneakyThrows;
+
+class Osi2PersistAttachmentServiceTest {
+
+	@Spy
+	@InjectMocks
+	private Osi2PersistAttachmentService service;
+
+	@Mock
+	private PostfachApiFacadeService postfachApiFacadeService;
+
+	@Mock
+	private Osi2AttachmentFileService attachmentFileService;
+
+	@DisplayName("persist attachments")
+	@Nested
+	class TestPersistAttachments {
+
+		@DisplayName("with no attachments")
+		@Nested
+		class TestWithNoAttachments {
+			private final Osi2Message osi2Message = Osi2MessageTestFactory.create();
+
+			@DisplayName("should return empty list")
+			@Test
+			void shouldReturnEmptyList() {
+				var result = service.persistAttachments(osi2Message);
+
+				assertThat(result).isEmpty();
+			}
+
+		}
+
+		@DisplayName("with two attachments")
+		@Nested
+		class TestWithTwoAttachments {
+
+			private final AttachmentInfo attachment1 = AttachmentInfoTestFactory.create();
+			private final AttachmentInfo attachment2 = AttachmentInfoTestFactory.createBuilder()
+					.guid(UPLOAD_GUID2)
+					.build();
+			private final Osi2Message osi2Message = Osi2MessageTestFactory.createBuilder()
+					.attachments(List.of(attachment1, attachment2))
+					.build();
+
+			@BeforeEach
+			void mock() {
+				doReturn(FILE_ID).when(service).persistAttachment(any(), eq(attachment1));
+				doReturn(FILE_ID_2).when(service).persistAttachment(any(), eq(attachment2));
+			}
+
+			@DisplayName("should call persistAttachment twice")
+			@Test
+			void shouldCallPersistAttachmentTwice() {
+				service.persistAttachments(osi2Message);
+
+				verify(service).persistAttachment(osi2Message, attachment1);
+				verify(service).persistAttachment(osi2Message, attachment2);
+			}
+
+			@DisplayName("should return")
+			@Test
+			void shouldReturn() {
+				var result = service.persistAttachments(osi2Message);
+
+				assertThat(result).containsExactly(FILE_ID, FILE_ID_2);
+			}
+		}
+	}
+
+	@DisplayName("persist attachment")
+	@Nested
+	class TestPersistAttachment {
+		private final Osi2Message osi2Message = Osi2MessageTestFactory.create();
+		private final AttachmentInfo attachment = AttachmentInfoTestFactory.create();
+		private final AttachmentFile attachmentFile = AttachmentFileTestFactory.create();
+
+		@Mock
+		private Resource content;
+
+		@BeforeEach
+		void mock() {
+			when(postfachApiFacadeService.downloadAttachment(any(), any())).thenReturn(content);
+			doReturn(attachmentFile).when(service).buildAttachmentFile(any(), any());
+			doReturn(FILE_ID).when(service).persistAttachmentFile(any(), any());
+		}
+
+		@DisplayName("should call persistAttachmentFile")
+		@Test
+		void shouldCallPersistAttachmentFile() {
+			service.persistAttachment(osi2Message, attachment);
+
+			verify(service).persistAttachmentFile(attachmentFile, content);
+		}
+
+		@DisplayName("should call buildAttachmentFile")
+		@Test
+		void shouldCallBuildAttachmentFile() {
+			service.persistAttachment(osi2Message, attachment);
+
+			verify(service).buildAttachmentFile(VORGANG_ID, attachment);
+		}
+
+		@DisplayName("should call downloadAttachment")
+		@Test
+		void shouldCallDownloadAttachment() {
+			service.persistAttachment(osi2Message, attachment);
+
+			verify(postfachApiFacadeService).downloadAttachment(MESSAGE_ID, UPLOAD_GUID);
+		}
+	}
+
+	@DisplayName("build attachment file")
+	@Nested
+	class TestBuildAttachmentFile {
+		private final AttachmentInfo attachment = AttachmentInfoTestFactory.create();
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = service.buildAttachmentFile(VORGANG_ID, attachment);
+
+			assertThat(result).usingRecursiveComparison()
+					.isEqualTo(AttachmentFileTestFactory.createBuilder()
+							.vorgangId(VORGANG_ID)
+							.build());
+		}
+	}
+
+	@DisplayName("persist attachment file")
+	@Nested
+	class TestPersistAttachmentFile {
+		private final AttachmentFile attachmentFile = AttachmentFileTestFactory.create();
+		@Mock
+		private Resource content;
+
+		@Mock
+		private InputStream inputStream;
+
+		@BeforeEach
+		@SneakyThrows
+		void mock() {
+			when(attachmentFileService.uploadFileAndReturnId(any(), any())).thenReturn(FILE_ID);
+			when(content.getInputStream()).thenReturn(inputStream);
+		}
+
+		@DisplayName("should close inputStream")
+		@Test
+		@SneakyThrows
+		void shouldCloseInputStream() {
+			service.persistAttachmentFile(attachmentFile, content);
+
+			verify(inputStream).close();
+		}
+
+		@DisplayName("should call uploadFileAndReturnId")
+		@Test
+		void shouldCallUploadFileAndReturnId() {
+			service.persistAttachmentFile(attachmentFile, content);
+
+			verify(attachmentFileService).uploadFileAndReturnId(attachmentFile, inputStream);
+		}
+
+		@DisplayName("should rethrow IOException")
+		@Test
+		@SneakyThrows
+		void shouldRethrowIoException() {
+			doThrow(new IOException("test")).when(inputStream).close();
+
+			assertThatThrownBy(() -> service.persistAttachmentFile(attachmentFile, content))
+					.isInstanceOf(Osi2RuntimeException.class);
+		}
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = service.persistAttachmentFile(attachmentFile, content);
+
+			assertThat(result).isEqualTo(FILE_ID);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..df51704620bc574bcbf1c1f01c4c5ff7786ab3fc
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/exception/Osi2ExceptionHandlerTest.java
@@ -0,0 +1,76 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.exception;
+
+import static de.ozgcloud.nachrichten.postfach.PostfachMessageCode.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.web.client.RestClientResponseException;
+
+class Osi2ExceptionHandlerTest {
+
+	@InjectMocks
+	@Spy
+	private Osi2ExceptionHandler handler;
+
+	@DisplayName("derive message code")
+	@Nested
+	class TestDeriveOsi2MessageCode {
+		@Mock
+		RestClientResponseException restClientResponseException;
+
+		@DisplayName("should derive message code from rest client response exception")
+		@Test
+		void shouldDeriveMessageCodeFromRestClientResponseException() {
+			doReturn(SERVER_CONNECTION_FAILED_MESSAGE_CODE).when(handler)
+					.deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+
+			var result = handler.deriveMessageCode(restClientResponseException);
+
+			assertThat(result).isEqualTo(SERVER_CONNECTION_FAILED_MESSAGE_CODE);
+		}
+
+		@DisplayName("should return processed failed error code by default")
+		@Test
+		void shouldReturnProcessedFailedErrorCodeByDefault() {
+			var result = handler.deriveMessageCode(null);
+
+			assertThat(result).isEqualTo(PROCESS_FAILED_MESSAGE_CODE);
+		}
+	}
+
+	@DisplayName("derive message code from rest client response exception")
+	@Nested
+	class TestDeriveOsi2MessageCodeFromRestClientResponseException {
+
+		@Mock
+		private RestClientResponseException restClientResponseException;
+
+		@DisplayName("should return unknown postfach id error code for 404")
+		@Test
+		void shouldReturnUnknownPostfachIdErrorCodeFor404() {
+			when(restClientResponseException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(404));
+
+			var result = handler.deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+
+			assertThat(result).isEqualTo(SEND_FAILED_UNKNOWN_POSTFACH_ID_MESSAGE_CODE);
+		}
+
+		@DisplayName("should return process failed message code by default")
+		@Test
+		void shouldReturnProcessFailedMessageCodeByDefault() {
+			when(restClientResponseException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(500));
+
+			var result = handler.deriveMessageCodeFromRestClientResponseException(restClientResponseException);
+
+			assertThat(result).isEqualTo(PROCESS_FAILED_MESSAGE_CODE);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/AttachmentExampleUploadUtil.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/AttachmentExampleUploadUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..8aa54916936532edcdc288378702867186469fe6
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/AttachmentExampleUploadUtil.java
@@ -0,0 +1,26 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.extension;
+
+import java.io.ByteArrayInputStream;
+import java.util.UUID;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.Osi2AttachmentFileService;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+public class AttachmentExampleUploadUtil {
+
+	public static final byte[] EXAMPLE_TEXT_DATA = LoremIpsum.getInstance().getParagraphs(5,100).getBytes();
+
+	public static String uploadTextFile(Osi2AttachmentFileService remoteService) {
+
+		return remoteService.uploadFileAndReturnId(AttachmentFile.builder()
+						.contentType("text/plain")
+						.name("test.txt")
+						.vorgangId(UUID.randomUUID().toString())
+				.build(), new ByteArrayInputStream(EXAMPLE_TEXT_DATA));
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java
index 71cd29cbd57f980c253f593a8c1a30f8286e38a4..84d367b808ca8a5fad1de979f57d46b32840351b 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java
@@ -3,6 +3,10 @@ package de.ozgcloud.nachrichten.postfach.osiv2.extension;
 import static com.github.tomakehurst.wiremock.client.WireMock.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory.*;
 
+import java.util.Optional;
+
+import jakarta.annotation.Nullable;
+
 import org.junit.jupiter.api.extension.AfterAllCallback;
 import org.junit.jupiter.api.extension.AfterEachCallback;
 import org.junit.jupiter.api.extension.BeforeAllCallback;
@@ -19,7 +23,7 @@ import lombok.extern.log4j.Log4j2;
 @Log4j2
 @Getter
 @RequiredArgsConstructor
-public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
+public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback {
 
 	private WireMockServer serviceKontoMockServer;
 	private WireMockServer postfachFacadeMockServer;
@@ -30,26 +34,36 @@ public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallba
 		setupServiceKontoMock();
 	}
 
-	@Override
-	public void afterEach(ExtensionContext context) {
-		postfachFacadeMockServer.resetAll();
-		serviceKontoMockServer.resetAll();
-	}
-
 	@Override
 	public void afterAll(ExtensionContext context) {
-		serviceKontoMockServer.shutdown();
-		postfachFacadeMockServer.shutdown();
+		if (serviceKontoMockServer != null) {
+			serviceKontoMockServer.shutdown();
+			serviceKontoMockServer = null;
+		}
+
+		if (postfachFacadeMockServer != null) {
+			postfachFacadeMockServer.shutdown();
+			postfachFacadeMockServer = null;
+		}
 	}
 
 	private void setupPostfachFacadeMock() {
-		postfachFacadeMockServer = new WireMockServer(0);
-		postfachFacadeMockServer.start();
+		postfachFacadeMockServer = setupWiremockServer(postfachFacadeMockServer, 32813);
 	}
 
 	private void setupServiceKontoMock() {
-		serviceKontoMockServer = new WireMockServer(0);
-		serviceKontoMockServer.start();
+		serviceKontoMockServer = setupWiremockServer(serviceKontoMockServer, 32812);
+	}
+
+	private WireMockServer setupWiremockServer(@Nullable WireMockServer existingServer, int port) {
+		if (existingServer != null && existingServer.isRunning()) {
+			existingServer.resetAll();
+			return existingServer;
+		} else {
+			var server = new WireMockServer(port);
+			server.start();
+			return server;
+		}
 	}
 
 	public String getAccessTokenUrl() {
@@ -57,11 +71,16 @@ public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallba
 	}
 
 	public String getPostfachFacadeUrl() {
-		return postfachFacadeMockServer.baseUrl();
+		return Optional.ofNullable(postfachFacadeMockServer)
+				.map(WireMockServer::baseUrl)
+				.orElseThrow(() -> new IllegalStateException("PostfachFacadeMockServer not initialized"));
 	}
 
 	@Override
 	public void beforeEach(ExtensionContext context) {
+		setupPostfachFacadeMock();
+		setupServiceKontoMock();
+
 		serviceKontoMockServer.stubFor(post("/access-token")
 				.withHeader("Content-Type", equalTo("application/x-www-form-urlencoded"))
 				.withFormParam("grant_type", equalTo("client_credentials"))
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/VorgangManagerContainer.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/VorgangManagerContainer.java
new file mode 100644
index 0000000000000000000000000000000000000000..a4d3ce6fa6c839f859d0102127988a414156633e
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/VorgangManagerContainer.java
@@ -0,0 +1,30 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.extension;
+
+import java.time.Duration;
+
+import jakarta.validation.constraints.NotNull;
+
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+public class VorgangManagerContainer extends GenericContainer<VorgangManagerContainer> {
+
+	private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker.ozg-sh.de/vorgang-manager");
+	private static final String DEFAULT_TAG = "latest";
+	public static final int PORT = 9090;
+
+	public VorgangManagerContainer() {
+		this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG));
+	}
+
+	public VorgangManagerContainer(@NotNull DockerImageName dockerImageName) {
+		super(dockerImageName);
+		addExposedPort(PORT);
+		super.withStartupTimeout(Duration.ofMinutes(2));
+	}
+
+	public String getAddress() {
+		return "%s:%d".formatted(getHost(), getMappedPort(PORT));
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/VorgangManagerServerExtension.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/VorgangManagerServerExtension.java
new file mode 100644
index 0000000000000000000000000000000000000000..6de18dcd892e9a35984e2d46dee5368be30a341e
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/VorgangManagerServerExtension.java
@@ -0,0 +1,83 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.extension;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.testcontainers.containers.MongoDBContainer;
+import org.testcontainers.containers.Network;
+import org.testcontainers.containers.output.OutputFrame;
+import org.testcontainers.lifecycle.Startables;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+@Getter
+@RequiredArgsConstructor
+public class VorgangManagerServerExtension implements BeforeAllCallback, AfterAllCallback {
+
+	static final String VORGANG_MANAGER_NETWORK_ALIAS = "vorgang-manager";
+	static final String MONGODB_NETWORK_ALIAS = "mongodb";
+
+	private VorgangManagerContainer vorgangManagerContainer;
+	private MongoDBContainer mongoDBContainer;
+
+	@Override
+	public void beforeAll(ExtensionContext context) {
+		if (vorgangManagerContainer == null || mongoDBContainer == null) {
+			setupVorgangManager();
+		}
+	}
+
+	@SneakyThrows
+	private void setupVorgangManager() {
+		var network = Network.newNetwork();
+		mongoDBContainer = new MongoDBContainer("mongo:7.0")
+				.withStartupTimeout(Duration.ofMinutes(1))
+				.withNetworkAliases(MONGODB_NETWORK_ALIAS)
+				.withNetwork(network);
+		vorgangManagerContainer = new VorgangManagerContainer()
+				.withEnv(Map.of(
+						"spring_profiles_active", "local",
+						"ozgcloud.vorgang-manager.serviceAddress", "%s:%d".formatted(VORGANG_MANAGER_NETWORK_ALIAS, VorgangManagerContainer.PORT),
+						"spring.data.mongodb.host", MONGODB_NETWORK_ALIAS,
+						"spring.data.mongodb.port", "27017",
+						"mongock_enabled", "false"
+				))
+				.withNetwork(network)
+				.withLogConsumer(this::logVorgangManager)
+				.withStartupTimeout(Duration.ofMinutes(2))
+				.dependsOn(mongoDBContainer);
+		Startables.deepStart(vorgangManagerContainer, mongoDBContainer).get();
+	}
+
+	private void logVorgangManager(OutputFrame outputFrame) {
+		var stream = outputFrame.getType() == OutputFrame.OutputType.STDOUT ? System.out : System.err;
+		stream.println("[vorgang-manager] " + outputFrame.getUtf8String().stripTrailing());
+	}
+
+	@Override
+	public void afterAll(ExtensionContext context) {
+		if (mongoDBContainer != null) {
+			mongoDBContainer.stop();
+			mongoDBContainer = null;
+		}
+		if (vorgangManagerContainer != null) {
+			vorgangManagerContainer.stop();
+			vorgangManagerContainer = null;
+		}
+	}
+
+	public String getVorgangManagerAddress() {
+		return Optional.ofNullable(vorgangManagerContainer)
+				.map(VorgangManagerContainer::getAddress)
+				.orElse("missing");
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/AttachmentFileTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/AttachmentFileTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..5d8056e79ea008aac5285a8eb1a01ca9bdcdba6c
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/AttachmentFileTestFactory.java
@@ -0,0 +1,20 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory.*;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentFile;
+
+public class AttachmentFileTestFactory {
+
+	public static AttachmentFile create() {
+		return createBuilder().build();
+	}
+
+	public static AttachmentFile.AttachmentFileBuilder createBuilder() {
+		return AttachmentFile.builder()
+				.name(UPLOAD_NAME)
+				.contentType(UPLOAD_CONTENT_TYPE)
+				.vorgangId(VORGANG_ID);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/AttachmentInfoTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/AttachmentInfoTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..8758edfbcd085c225876fa9c8e4ff34dcdbaee74
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/AttachmentInfoTestFactory.java
@@ -0,0 +1,20 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory.*;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentInfo;
+
+public class AttachmentInfoTestFactory {
+
+	public static AttachmentInfo create() {
+		return createBuilder().build();
+	}
+
+	public static AttachmentInfo.AttachmentInfoBuilder createBuilder() {
+		return AttachmentInfo.builder()
+				.name(UPLOAD_NAME)
+				.contentType(UPLOAD_CONTENT_TYPE)
+				.guid(UPLOAD_GUID)
+				.size(UPLOAD_SIZE);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/DummyStringBasedIdentifier.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/DummyStringBasedIdentifier.java
deleted file mode 100644
index cc2ef17e289807775b21c42b498a969faba0d1a5..0000000000000000000000000000000000000000
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/DummyStringBasedIdentifier.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.ozgcloud.nachrichten.postfach.osiv2.factory;
-
-import de.ozgcloud.nachrichten.postfach.PostfachAddressIdentifier;
-import lombok.Builder;
-import lombok.Getter;
-
-@Builder
-@Getter
-public class DummyStringBasedIdentifier implements PostfachAddressIdentifier {
-
-	private String mailboxId;
-
-	@Override
-	public boolean isStringBasedIdentifier() {
-		return true;
-	}
-
-	@Override
-	public String toString() {
-		return mailboxId;
-	}
-}
-
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/FileChunkInfoTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/FileChunkInfoTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..f720d9bf668a6cff0a7401c249142d7e23d10aac
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/FileChunkInfoTestFactory.java
@@ -0,0 +1,18 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
+
+public class FileChunkInfoTestFactory {
+
+	public static final Integer CHUNK_INDEX = 1;
+
+	public static FileChunkInfo create() {
+		return createBuilder().build();
+	}
+
+	public static FileChunkInfo.FileChunkInfoBuilder createBuilder() {
+		return FileChunkInfo.builder()
+				.upload(Osi2FileUploadTestFactory.create())
+				.chunkIndex(CHUNK_INDEX);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/GrpcOzgFileTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/GrpcOzgFileTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..c72aa9d89db1e52e5fb319a7bc5a8ea7211f51c2
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/GrpcOzgFileTestFactory.java
@@ -0,0 +1,24 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import de.ozgcloud.vorgang.grpc.file.GrpcOzgFile;
+
+public class GrpcOzgFileTestFactory {
+
+	public static final String FILE_ID = "ozgfileId1";
+	public static final String FILE_ID_2 = "ozgfileId2";
+	public static final String FILE_NAME = "ozgfileName1";
+	public static final String FILE_CONTENT_TYPE = "ozgfileContentType1";
+	public static final Long FILE_SIZE = 124L;
+
+	public static GrpcOzgFile create() {
+		return createBuilder().build();
+	}
+
+	public static GrpcOzgFile.Builder createBuilder() {
+		return GrpcOzgFile.newBuilder()
+				.setId(FILE_ID)
+				.setName(FILE_NAME)
+				.setContentType(FILE_CONTENT_TYPE)
+				.setSize(FILE_SIZE);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessageTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessageTestFactory.java
index 75a60a29f366e9ea29f68686f234a02c0d8fd987..278b052eec80c315137367d08c0af3d2f74e244e 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessageTestFactory.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessageTestFactory.java
@@ -1,15 +1,25 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.factory;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory.*;
+
+import java.util.Arrays;
 import java.util.UUID;
 
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveAttachment;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessage;
 
 public class MessageExchangeReceiveMessageTestFactory {
 
-	public static final UUID MESSAGE_ID = UUID.randomUUID();
+	public static final String ATTACHMENT_1_ID = UUID.randomUUID().toString();
+	public static final String ATTACHMENT_2_ID = UUID.randomUUID().toString();
 
-	public static MessageExchangeReceiveMessage create() {
+	public static MessageExchangeReceiveMessage create(String... attachmentIds) {
 		return new MessageExchangeReceiveMessage()
-				.guid(MESSAGE_ID);
+				.guid(UUID.fromString(MESSAGE_ID))
+				.attachments(attachmentIds.length == 0 ? null : Arrays.stream(attachmentIds)
+						.map(attachmentId -> MessageExchangeReceiveAttachment.builder()
+								.guid(UUID.fromString(attachmentId))
+								.build())
+						.toList());
 	}
 }
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java
index a104cfc9d8ee0dc6d185b871a27103b7f9d55542..70525c804aa0c2d262f54afafcde661b6ebb4bc6 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeReceiveMessagesResponseTestFactory.java
@@ -14,7 +14,7 @@ public class MessageExchangeReceiveMessagesResponseTestFactory {
 		return new MessageExchangeReceiveMessagesResponse()
 				.messages(Arrays.stream(uuids)
 						.map(uuid -> MessageExchangeReceiveMessageTestFactory.create()
-								.guid(UUID.fromString(uuid)))
+								.guid(uuid == null ? null : UUID.fromString(uuid)))
 						.toList());
 	}
 
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeSendMessageResponseTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeSendMessageResponseTestFactory.java
index 0c296a6f816a801dcf5a7f2b4ba6e6e3b8e1d653..d0c543cd29af2a128b293579450fcf7b5dd3663e 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeSendMessageResponseTestFactory.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/MessageExchangeSendMessageResponseTestFactory.java
@@ -1,13 +1,19 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.factory;
 
-import static de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessageTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory.*;
+
+import java.util.UUID;
 
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeSendMessageResponse;
 
 public class MessageExchangeSendMessageResponseTestFactory {
 
 	public static MessageExchangeSendMessageResponse create() {
-		return new MessageExchangeSendMessageResponse()
-				.messageId(MESSAGE_ID);
+		return createBuilder().build();
+	}
+
+	public static MessageExchangeSendMessageResponse.Builder createBuilder() {
+		return MessageExchangeSendMessageResponse.builder()
+				.messageId(UUID.fromString(MESSAGE_ID));
 	}
 }
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/Osi2FileUploadTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/Osi2FileUploadTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..a7e615363f7c878fbdba76251584aeb514664e2d
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/Osi2FileUploadTestFactory.java
@@ -0,0 +1,35 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment.*;
+
+import java.util.UUID;
+
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.apilib.file.OzgCloudFileId;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
+
+public class Osi2FileUploadTestFactory {
+
+	public static final String UPLOAD_FILE_ID = UUID.randomUUID().toString();
+	public static final String UPLOAD_GUID = UUID.randomUUID().toString();
+	public static final String UPLOAD_GUID2 = UUID.randomUUID().toString();
+	public static final String UPLOAD_NAME = "upload.txt";
+	public static final String UPLOAD_CONTENT_TYPE = "text/plain";
+	public static final long NUMBER_OF_CHUNKS = 5;
+	public static final long UPLOAD_SIZE = CHUNK_SIZE * NUMBER_OF_CHUNKS;
+
+	public static Osi2Attachment create() {
+		return createBuilder().build();
+	}
+
+	public static Osi2Attachment.Osi2AttachmentBuilder createBuilder() {
+		return Osi2Attachment.builder()
+				.guid(UPLOAD_GUID)
+				.file(OzgCloudFile.builder()
+						.id(new OzgCloudFileId(UPLOAD_FILE_ID))
+						.name(UPLOAD_NAME)
+						.contentType(UPLOAD_CONTENT_TYPE)
+						.size(UPLOAD_SIZE)
+						.build());
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/Osi2MessageTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/Osi2MessageTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..8d40f117e113c3c070288228e011c27c3ead4500
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/Osi2MessageTestFactory.java
@@ -0,0 +1,29 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory.*;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+
+public class Osi2MessageTestFactory {
+
+	public static Osi2Message create() {
+		return createBuilder().build();
+	}
+
+	public static Osi2Message.Osi2MessageBuilder createBuilder() {
+		return Osi2Message.builder()
+				.messageId(MESSAGE_ID)
+				.mailBody(MAIL_BODY)
+				.subject(MAIL_SUBJECT)
+				.replyOption(PostfachNachricht.ReplyOption.FORBIDDEN)
+				.createdAt(ZonedDateTime.now())
+				.vorgangId(VORGANG_ID)
+				.attachments(List.of())
+				.postfachAddress(PostfachAddressTestFactory.create());
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/PostfachAddressTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/PostfachAddressTestFactory.java
index 046f298c7060ef696550ed252f7b51c097e297fe..de986e02f39c9781e3af9be92741d4d0b64994de 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/PostfachAddressTestFactory.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/PostfachAddressTestFactory.java
@@ -1,10 +1,13 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.factory;
 
+import java.util.UUID;
+
 import de.ozgcloud.nachrichten.postfach.PostfachAddress;
+import de.ozgcloud.nachrichten.postfach.StringBasedIdentifier;
 
 public class PostfachAddressTestFactory {
 
-	public static final String MAILBOX_ID = "testMailboxId";
+	public static final String MAILBOX_ID = UUID.randomUUID().toString();
 	public static final String SERVICE_KONTO_TYPE = "TYPE1";
 
 	public static PostfachAddress create() {
@@ -14,9 +17,9 @@ public class PostfachAddressTestFactory {
 	public static PostfachAddress.PostfachAddressBuilder createBuilder() {
 		return PostfachAddress.builder()
 				.type(1)
-				.serviceKontoType("TYPE1")
-				.identifier(DummyStringBasedIdentifier.builder()
-						.mailboxId(MAILBOX_ID)
+				.serviceKontoType(SERVICE_KONTO_TYPE)
+				.identifier(StringBasedIdentifier.builder()
+						.postfachId(MAILBOX_ID)
 						.build());
 	}
 }
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/QuarantineFileResultTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/QuarantineFileResultTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..c50c8233f6224e68f54c3c50e68a52f0bcef05d4
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/QuarantineFileResultTestFactory.java
@@ -0,0 +1,19 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineFileResult;
+
+public class QuarantineFileResultTestFactory {
+
+	public static final String ERROR_STRING = LoremIpsum.getInstance().getWords(10);
+
+	public static QuarantineFileResult create() {
+		return createBuilder().build();
+	}
+
+	public static QuarantineFileResult.Builder createBuilder() {
+		return QuarantineFileResult.builder()
+				.success(true);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyFilesTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyFilesTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..1974823886b4b815b84756089b97012c9ecf31de
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyFilesTestFactory.java
@@ -0,0 +1,25 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.factory;
+
+import java.util.UUID;
+
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyFiles;
+
+public class V1ReplyFilesTestFactory {
+
+	public static final String ATTACHMENT_GUID = UUID.randomUUID().toString();
+	public static final String ATTACHMENT_NAME = "test.txt";
+	public static final String ATTACHMENT_CONTENT_TYPE = "text/plain";
+	public static final Long ATTACHMENT_SIZE = 1000L;
+
+	public static V1ReplyFiles create() {
+		return createBuilder().build();
+	}
+
+	public static V1ReplyFiles.Builder createBuilder() {
+		return V1ReplyFiles.builder()
+				.guid(UUID.fromString(ATTACHMENT_GUID))
+				.name(ATTACHMENT_NAME)
+				.mimeType(ATTACHMENT_CONTENT_TYPE)
+				.size(ATTACHMENT_SIZE);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyMessageTestFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyMessageTestFactory.java
index 2bb4c78fa25a9d2a80f4b249c9b0e2b57b8dea74..eb41e7858d336007b5e8ad8814084aab06a60323 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyMessageTestFactory.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/V1ReplyMessageTestFactory.java
@@ -2,6 +2,7 @@ package de.ozgcloud.nachrichten.postfach.osiv2.factory;
 
 import java.time.OffsetDateTime;
 import java.time.ZonedDateTime;
+import java.util.List;
 import java.util.UUID;
 
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1EidasLevel;
@@ -23,14 +24,20 @@ public class V1ReplyMessageTestFactory {
 	public static final ZonedDateTime RESPONSE_TIME = ZonedDateTime.now();
 
 	public static V1ReplyMessage create() {
-		return new V1ReplyMessage()
+		return createBuilder().build();
+	}
+
+	public static V1ReplyMessage.Builder createBuilder() {
+		return V1ReplyMessage.builder()
 				.sequencenumber(SEQUENCE_NUMMER)
 				.subject(SUBJECT)
 				.replyAction(V1ReplyBehavior.REPLYPOSSIBLE)
 				.isObligatory(false)
 				.eidasLevel(V1EidasLevel.LOW)
 				.isHtml(false)
+				.body(REPLY_BODY)
 				.guid(UUID.fromString(MESSAGE_ID))
+				.files(List.of(V1ReplyFilesTestFactory.create()))
 				.messageBox(UUID.fromString(MESSAGE_BOX_ID))
 				.responseTime(OffsetDateTime.of(RESPONSE_TIME.toLocalDateTime(), RESPONSE_TIME.getOffset()));
 	}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2AttachmentTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2AttachmentTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..bbe1e1f9f47bc69a33bfbfc23206452c0165552c
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/model/Osi2AttachmentTest.java
@@ -0,0 +1,60 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.model;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment.*;
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import de.ozgcloud.apilib.file.OzgCloudFileTestFactory;
+
+public class Osi2AttachmentTest {
+
+	@DisplayName("calculate number of chunks")
+	@Nested
+	class TestCalculateNumberOfChunks {
+
+		@DisplayName("should return zero")
+		@Test
+		void shouldReturnZero() {
+			var upload = createWithSize(0L);
+
+			var result = upload.numberOfChunks();
+
+			assertThat(result).isZero();
+		}
+
+		@DisplayName("should return one")
+		@ParameterizedTest
+		@ValueSource(longs = { 1, CHUNK_SIZE - 1, CHUNK_SIZE })
+		void shouldReturnOne(long fileLength) {
+			var upload = createWithSize(fileLength);
+
+			var result = upload.numberOfChunks();
+
+			assertThat(result).isEqualTo(1L);
+		}
+
+		@DisplayName("should return two")
+		@ParameterizedTest
+		@ValueSource(longs = { CHUNK_SIZE + 1, CHUNK_SIZE * 2 - 1, CHUNK_SIZE * 2 })
+		void shouldReturnTwo(long fileLength) {
+			var upload = createWithSize(fileLength);
+
+			var result = upload.numberOfChunks();
+
+			assertThat(result).isEqualTo(2L);
+		}
+
+		private Osi2Attachment createWithSize(long size) {
+			return Osi2Attachment.builder()
+					.file(OzgCloudFileTestFactory.createBuilder()
+							.size(size)
+							.build())
+					.build();
+		}
+	}
+}
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2MessageMapperTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2MessageMapperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..89d64bac79517999fc840fd5c2857126c879fa51
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2MessageMapperTest.java
@@ -0,0 +1,98 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory.*;
+import static java.util.Collections.*;
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+
+import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2MessageTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+
+class Osi2MessageMapperTest {
+
+	private final Osi2MessageMapper mapper = Mappers.getMapper(Osi2MessageMapper.class);
+
+	@DisplayName("to PostfachNachricht")
+	@Nested
+	class TestToPostfachNachricht {
+		private final Osi2Message osi2Message = Osi2MessageTestFactory.create();
+		private final String attachmentId1 = "attachmentId1";
+		private final String attachmentId2 = "attachmentId2";
+
+		@DisplayName("should map vorgang id")
+		@Test
+		void shouldMapVorgangId() {
+			var result = doMapping();
+
+			assertThat(result.getVorgangId()).isEqualTo(VORGANG_ID);
+		}
+
+		@DisplayName("should map mail body")
+		@Test
+		void shouldMapMailBody() {
+			var result = doMapping();
+
+			assertThat(result.getMailBody()).isEqualTo(MAIL_BODY);
+		}
+
+		@DisplayName("should map subject")
+		@Test
+		void shouldMapSubject() {
+			var result = doMapping();
+
+			assertThat(result.getSubject()).isEqualTo(MAIL_SUBJECT);
+		}
+
+		@DisplayName("should map reply option")
+		@Test
+		void shouldMapReplyOption() {
+			var result = doMapping();
+
+			assertThat(result.getReplyOption()).isEqualTo(PostfachNachricht.ReplyOption.FORBIDDEN);
+		}
+
+		@DisplayName("should map created at")
+		@Test
+		void shouldMapCreatedAt() {
+			var result = doMapping();
+
+			assertThat(result.getCreatedAt()).isEqualTo(osi2Message.createdAt());
+		}
+
+		@DisplayName("should map postfach address")
+		@Test
+		void shouldMapPostfachAddress() {
+			var result = doMapping();
+
+			assertThat(result.getPostfachAddress()).isEqualTo(osi2Message.postfachAddress());
+		}
+
+		@DisplayName("should map direction")
+		@Test
+		void shouldMapDirection() {
+			var result = doMapping();
+
+			assertThat(result.getDirection()).isEqualTo(PostfachNachricht.Direction.IN);
+		}
+
+		@DisplayName("should map attachments")
+		@Test
+		void shouldMapAttachments() {
+			var result = doMapping();
+
+			assertThat(result.getAttachments()).containsExactly(attachmentId1, attachmentId2);
+		}
+
+		private PostfachNachricht doMapping() {
+			return mapper.toPostfachNachricht(osi2Message, List.of(attachmentId1, attachmentId2));
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2PostfachServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2PostfachServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7077d02f8f7ad83c5dd3171056079beac450b06e
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2PostfachServiceTest.java
@@ -0,0 +1,96 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
+
+class Osi2PostfachServiceTest {
+
+	@Spy
+	@InjectMocks
+	private Osi2PostfachService service;
+
+	@Mock
+	private PostfachApiFacadeService postfachApiFacadeService;
+	@Mock
+	private Osi2QuarantineService quarantineService;
+
+	@DisplayName("send message")
+	@Nested
+	class TestSendOsi2Message {
+		private final PostfachNachricht nachricht = PostfachNachrichtTestFactory.create();
+		private final List<Osi2Attachment> uploadFiles = List.of(Osi2FileUploadTestFactory.create());
+
+		@BeforeEach
+		void mock() {
+			when(quarantineService.uploadAttachments(any())).thenReturn(uploadFiles);
+		}
+
+		@DisplayName("should send message")
+		@Test
+		void shouldSendMessage() {
+			service.sendMessage(nachricht);
+
+			verify(postfachApiFacadeService).sendMessage(nachricht, uploadFiles);
+		}
+
+		@DisplayName("should upload attachments of nachricht")
+		@Test
+		void shouldUploadAttachmentsOfNachricht() {
+			service.sendMessage(nachricht);
+
+			verify(quarantineService).uploadAttachments(nachricht.getAttachments());
+		}
+	}
+
+	@DisplayName("receive messages")
+	@Nested
+	class TestReceiveOsi2Message {
+
+		@DisplayName("with two pending messages")
+		@Nested
+		class TestWithTwoPendingMessages {
+
+			@BeforeEach
+			void mock() {
+				when(postfachApiFacadeService.fetchPendingMessageIds()).thenReturn(List.of(MESSAGE_ID_1, MESSAGE_ID_2));
+				doReturn(PostfachNachrichtTestFactory.createBuilder().messageId(MESSAGE_ID_1).build())
+						.when(service).fetchPostfachNachricht(MESSAGE_ID_1);
+				doReturn(PostfachNachrichtTestFactory.createBuilder().messageId(MESSAGE_ID_2).build())
+						.when(service).fetchPostfachNachricht(MESSAGE_ID_2);
+			}
+
+			@DisplayName("should return")
+			@Test
+			void shouldReturn() {
+				var messages = receiveMessages();
+
+				assertThat(messages)
+						.extracting(PostfachNachricht::getMessageId)
+						.containsExactly(MESSAGE_ID_1, MESSAGE_ID_2);
+			}
+
+		}
+
+		private Stream<PostfachNachricht> receiveMessages() {
+			return service.receiveMessages();
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c411ff93efc7e6beb5cfe416f00fc2128b36fdf7
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2QuarantineServiceTest.java
@@ -0,0 +1,466 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2QuarantineService.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Spy;
+
+import de.ozgcloud.apilib.file.OzgCloudFile;
+import de.ozgcloud.apilib.file.OzgCloudFileId;
+import de.ozgcloud.apilib.file.OzgCloudFileTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.attachment.Osi2AttachmentFileService;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
+import lombok.SneakyThrows;
+
+class Osi2QuarantineServiceTest {
+
+	@InjectMocks
+	@Spy
+	private Osi2QuarantineService service;
+
+	@Mock
+	private PostfachApiFacadeService postfachApiFacadeService;
+
+	@Mock
+	private Osi2AttachmentFileService binaryFileService;
+
+	private final OzgCloudFile file1 = OzgCloudFileTestFactory.createBuilder()
+			.id(new OzgCloudFileId(UPLOAD_FILE_ID))
+			.size(1000L)
+			.build();
+	private final OzgCloudFile file2 = OzgCloudFileTestFactory.createBuilder()
+			.size(2000L)
+			.build();
+
+	private final Osi2Attachment upload1 = Osi2FileUploadTestFactory.createBuilder()
+			.file(file1)
+			.build();
+	private final Osi2Attachment upload2 = Osi2FileUploadTestFactory.createBuilder()
+			.guid(UUID.randomUUID().toString())
+			.file(file2)
+			.build();
+	private final Osi2Attachment upload3 = Osi2FileUploadTestFactory.createBuilder()
+			.guid(UUID.randomUUID().toString())
+			.build();
+
+	private final List<Osi2Attachment> uploads = List.of(upload1, upload2, upload3);
+
+	@DisplayName("upload attachments")
+	@Nested
+	class TestUploadAttachments {
+
+		@BeforeEach
+		void mock() {
+			when(binaryFileService.getFileMetadataOfIds(any())).thenReturn(List.of(file1));
+			doReturn(List.of(upload1)).when(service).uploadFiles(any());
+		}
+
+		@DisplayName("should call getFileMetadataOfIds")
+		@Test
+		void shouldCallGetFileMetadataOfIds() {
+			uploadAttachments();
+
+			verify(binaryFileService).getFileMetadataOfIds(List.of(UPLOAD_FILE_ID));
+		}
+
+		@DisplayName("should call uploadFiles")
+		@Test
+		void shouldCallUploadFilesToQuarantineWithLargestFileFirst() {
+			uploadAttachments();
+
+			verify(service).uploadFiles(List.of(file1));
+		}
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var result = uploadAttachments();
+
+			assertThat(result).containsExactly(upload1);
+		}
+
+		List<Osi2Attachment> uploadAttachments() {
+			return service.uploadAttachments(List.of(UPLOAD_FILE_ID));
+		}
+	}
+
+	@DisplayName("upload files")
+	@Nested
+	class TestUploadFiles {
+
+		@BeforeEach
+		void mock() {
+			doReturn(uploads).when(service).deriveSortedUploadFiles(any());
+		}
+
+		@DisplayName("without exception")
+		@Nested
+		class TestWithoutException {
+			@BeforeEach
+			void mock() {
+				doNothing().when(service).tryUploadSortedFiles(any());
+			}
+
+			@DisplayName("should call deriveSortedUploadFiles")
+			@Test
+			void shouldCallDeriveSortedUploadFiles() {
+				uploadFiles();
+
+				verify(service).deriveSortedUploadFiles(List.of(file1));
+			}
+
+			@DisplayName("should call uploadFiles")
+			@Test
+			void shouldCallUploadFilesToQuarantineWithLargestFileFirst() {
+				uploadFiles();
+
+				verify(service).tryUploadSortedFiles(uploads);
+			}
+
+			@DisplayName("should return")
+			@Test
+			void shouldReturn() {
+				var result = uploadFiles();
+
+				assertThat(result).containsExactly(upload1, upload2, upload3);
+			}
+		}
+
+		@DisplayName("with exception")
+		@Nested
+		class TestWithException {
+
+			private final RuntimeException exception = new RuntimeException("test");
+
+			@BeforeEach
+			void mock() {
+				doThrow(exception).when(service).tryUploadSortedFiles(any());
+			}
+
+			@DisplayName("should delete attachments")
+			@Test
+			void shouldDeleteAttachments() {
+				try {
+					uploadFiles();
+				} catch (RuntimeException e) {
+					// ignore
+				}
+
+				verify(service).deleteAttachments(uploads);
+			}
+
+			@DisplayName("should rethrow exception")
+			@Test
+			void shouldRethrowException() {
+				assertThatThrownBy(TestUploadFiles.this::uploadFiles)
+						.isEqualTo(exception);
+			}
+		}
+
+		List<Osi2Attachment> uploadFiles() {
+			return service.uploadFiles(List.of(file1));
+		}
+	}
+
+	@DisplayName("derive sorted upload files")
+	@Nested
+	class TestDeriveSortedUploadFiles {
+
+		@DisplayName("should return largest file first")
+		@Test
+		void shouldReturnLargestFileFirst() {
+			var result = service.deriveSortedUploadFiles(List.of(file1, file2));
+
+			assertThat(result)
+					.extracting(Osi2Attachment::file)
+					.containsExactly(file2, file1);
+		}
+
+		@DisplayName("should have random upload messageGuid")
+		@Test
+		void shouldHaveRandomUploadGuid() {
+			var result = service.deriveSortedUploadFiles(List.of(file1, file2));
+
+			assertThat(result)
+					.extracting(Osi2Attachment::guid)
+					.extracting(String::length)
+					.containsExactly(36, 36);
+		}
+	}
+
+	@DisplayName("try upload sorted files")
+	@Nested
+	class TestTryUploadSortedFiles {
+
+		@BeforeEach
+		void mock() {
+			doNothing().when(service).uploadFilesToQuarantine(any());
+			doNothing().when(service).waitForVirusScan(any());
+		}
+
+		@DisplayName("should call uploadFilesToQuarantine")
+		@Test
+		void shouldCallUploadFilesToQuarantine() {
+			tryUploadSortedFiles();
+
+			verify(service).uploadFilesToQuarantine(uploads);
+		}
+
+		@DisplayName("should call waitForVirusScan")
+		@Test
+		void shouldCallWaitForVirusScan() {
+			tryUploadSortedFiles();
+
+			verify(service).waitForVirusScan(uploads);
+		}
+
+		void tryUploadSortedFiles() {
+			service.tryUploadSortedFiles(uploads);
+		}
+	}
+
+	@DisplayName("upload files to quarantine")
+	@Nested
+	class TestUploadFilesToQuarantine {
+
+		@DisplayName("should call uploadFileToQuarantine for each file")
+		@Test
+		void shouldCallUploadFileToQuarantineForEachFile() {
+			doNothing().when(service).uploadFileToQuarantine(any());
+
+			service.uploadFilesToQuarantine(uploads);
+
+			verify(service).uploadFileToQuarantine(upload1);
+			verify(service).uploadFileToQuarantine(upload2);
+			verify(service).uploadFileToQuarantine(upload3);
+		}
+	}
+
+	@DisplayName("upload file to quarantine")
+	@Nested
+	class TestUploadFileToQuarantine {
+
+		@Mock
+		private InputStream fileInputStream;
+
+		@BeforeEach
+		void mock() {
+			when(binaryFileService.downloadFileContent(any())).thenReturn(fileInputStream);
+			doNothing().when(service).uploadInputStreamToQuarantine(any(), any());
+		}
+
+		@DisplayName("should call streamFileContent")
+		@Test
+		void shouldCallStreamFileContent() {
+			uploadFileToQuarantine();
+
+			verify(binaryFileService).downloadFileContent(UPLOAD_FILE_ID);
+		}
+
+		@DisplayName("should call uploadInputStreamToQuarantine")
+		@Test
+		void shouldCallUploadInputStreamToQuarantine() {
+			uploadFileToQuarantine();
+
+			verify(service).uploadInputStreamToQuarantine(upload1, fileInputStream);
+		}
+
+		@DisplayName("should close inputStream")
+		@Test
+		@SneakyThrows
+		void shouldCloseInputStream() {
+			uploadFileToQuarantine();
+
+			verify(fileInputStream).close();
+		}
+
+		@DisplayName("should throw OsiPostfachException if close fails with IOException")
+		@Test
+		@SneakyThrows
+		void shouldThrowOsiPostfachException() {
+			doThrow(new IOException("test")).when(fileInputStream).close();
+
+			assertThatThrownBy(this::uploadFileToQuarantine)
+					.isInstanceOf(Osi2RuntimeException.class);
+		}
+
+		void uploadFileToQuarantine() {
+			service.uploadFileToQuarantine(upload1);
+		}
+	}
+
+	@DisplayName("stream file chunk infos")
+	@Nested
+	class TestStreamFileChunkInfos {
+
+		private final Osi2Attachment upload = Osi2FileUploadTestFactory.createBuilder()
+				.file(OzgCloudFileTestFactory.createBuilder()
+						.size(CHUNK_SIZE * 2)
+						.build())
+				.build();
+
+		private final FileChunkInfo chunkInfo1 = FileChunkInfo.builder()
+				.upload(upload)
+				.chunkIndex(0)
+				.build();
+		private final FileChunkInfo chunkInfo2 = FileChunkInfo.builder()
+				.upload(upload)
+				.chunkIndex(1)
+				.build();
+		private final FileChunkInfo emptyChunkInfo = FileChunkInfo.builder()
+				.upload(upload)
+				.chunkIndex(2)
+				.build();
+
+		@DisplayName("should stream chunk infos")
+		@Test
+		void shouldStreamChunkInfos() {
+			var result = service.streamFileChunkInfos(upload).toList();
+
+			assertThat(result).containsExactly(chunkInfo1, chunkInfo2, emptyChunkInfo);
+		}
+	}
+
+	@DisplayName("check virus scan completed")
+	@Nested
+	class TestCheckVirusScanCompleted {
+
+		@DisplayName("should return true if all virus scans completed")
+		@Test
+		void shouldReturnTrueIfAllVirusScansCompleted() {
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload1);
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload2);
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload3);
+
+			var result = service.checkVirusScanCompleted(uploads);
+
+			assertThat(result).isTrue();
+		}
+
+		@DisplayName("should return false if one virus scan not completed")
+		@Test
+		void shouldReturnFalseIfOneVirusScanNotCompleted() {
+			doReturn(true).when(service).checkOneVirusScanCompleted(upload1);
+			doReturn(false).when(service).checkOneVirusScanCompleted(upload2);
+
+			var result = service.checkVirusScanCompleted(uploads);
+
+			assertThat(result).isFalse();
+		}
+	}
+
+	@DisplayName("check one virus scan completed")
+	@Nested
+	class TestCheckOneVirusScanCompleted {
+		private final Osi2Attachment upload1 = Osi2FileUploadTestFactory.create();
+
+		@DisplayName("should call checkUploadSuccessful")
+		@Test
+		@SneakyThrows
+		void shouldCallCheckUploadSuccessful() {
+			service.checkOneVirusScanCompleted(upload1);
+
+			verify(postfachApiFacadeService).checkUploadSuccessful(UPLOAD_GUID);
+		}
+
+		@DisplayName("should return")
+		@ParameterizedTest
+		@ValueSource(booleans = { true, false })
+		@SneakyThrows
+		void shouldReturn(boolean value) {
+			when(postfachApiFacadeService.checkUploadSuccessful(any())).thenReturn(value);
+
+			var result = service.checkOneVirusScanCompleted(upload1);
+
+			assertThat(result).isEqualTo(value);
+		}
+
+		@DisplayName("should throw OsiRuntimeException with file metadata")
+		@Test
+		@SneakyThrows
+		void shouldThrowOsiRuntimeExceptionWithFileMetadata() {
+			var uploadException = new Osi2UploadException(QuarantineStatus.UNSAFE);
+			when(postfachApiFacadeService.checkUploadSuccessful(any())).thenThrow(uploadException);
+
+			assertThatThrownBy(() -> service.checkOneVirusScanCompleted(upload1))
+					.isInstanceOf(Osi2RuntimeException.class)
+					.hasMessageContaining(upload1.getLoggableString())
+					.hasCause(uploadException);
+		}
+	}
+
+	@DisplayName("wait for virus scan")
+	@Nested
+	class TestWaitForVirusScan {
+
+		@DisplayName("should call waitUntil")
+		@Test
+		void shouldCallWaitUntil() {
+			doReturn(true).when(service).checkVirusScanCompleted(any());
+
+			service.waitForVirusScan(uploads);
+
+			verify(service).checkVirusScanCompleted(uploads);
+		}
+
+		@DisplayName("should call with polling interval and timeout")
+		@Test
+		void shouldCallWithPollingIntervalAndTimeout() {
+			try (MockedStatic<WaitUtil> mockedStatic = mockStatic(WaitUtil.class)) {
+				mockedStatic.when(() -> WaitUtil.waitUntil(any(), any(), any())).thenReturn(true);
+
+				service.waitForVirusScan(uploads);
+
+				mockedStatic.verify(() -> WaitUtil.waitUntil(any(), eq(POLLING_INTERVAL), eq(POLLING_TIMEOUT)));
+			}
+		}
+
+		@DisplayName("should throw if scan not completed before timeout")
+		@Test
+		void shouldThrowIfScanNotCompletedBeforeTimeout() {
+			try (MockedStatic<WaitUtil> mockedStatic = mockStatic(WaitUtil.class)) {
+				mockedStatic.when(() -> WaitUtil.waitUntil(any(), any(), any())).thenReturn(false);
+
+				assertThatThrownBy(() -> service.waitForVirusScan(uploads))
+						.isInstanceOf(Osi2RuntimeException.class);
+			}
+		}
+	}
+
+	@DisplayName("delete attachments")
+	@Nested
+	class TestDeleteAttachments {
+		@DisplayName("should call deleteAttachment for uploads")
+		@Test
+		void shouldCallDeleteAttachmentForUploads() {
+			service.deleteAttachments(uploads);
+
+			verify(postfachApiFacadeService).deleteFileUpload(upload1.guid());
+			verify(postfachApiFacadeService).deleteFileUpload(upload2.guid());
+			verify(postfachApiFacadeService).deleteFileUpload(upload3.guid());
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapperTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapperTest.java
index 2f1cbe4af9940226535af88324de563b10261338..bdf27c7d4d9171eb1c1b60da3ede0638510b176b 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapperTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2RequestMapperTest.java
@@ -1,9 +1,13 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachAddressTestFactory.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory.*;
+import static java.util.Collections.*;
 import static org.assertj.core.api.Assertions.*;
 
+import java.util.List;
+import java.util.UUID;
 import java.util.stream.Stream;
 
 import org.junit.jupiter.api.DisplayName;
@@ -14,14 +18,20 @@ import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.mapstruct.factory.Mappers;
 
+import de.ozgcloud.apilib.file.OzgCloudFileTestFactory;
 import de.ozgcloud.nachrichten.postfach.PostfachAddress;
-import de.ozgcloud.nachrichten.postfach.PostfachAddressIdentifier;
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.FileChunkInfoTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachAddressTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.DomainChunkMetaData;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeFiles;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.OutSendMessageRequestV2;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1EidasLevel;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1FilestorageTarget;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyBehavior;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
 
 class Osi2RequestMapperTest {
 
@@ -56,11 +66,6 @@ class Osi2RequestMapperTest {
 					null,
 					Arguments.of(PostfachAddressTestFactory.createBuilder()
 							.identifier(null)
-							.build()),
-					Arguments.of(PostfachAddressTestFactory.createBuilder()
-							.identifier(new PostfachAddressIdentifier() {
-
-							})
 							.build())
 			);
 		}
@@ -68,7 +73,7 @@ class Osi2RequestMapperTest {
 
 	@DisplayName("map OutSendMessageRequestV2")
 	@Nested
-	class TestMapOutSendMessageRequestV2 {
+	class TestMapOutSendOsi2MessageRequestV2 {
 
 		@DisplayName("should map sequence number")
 		@Test
@@ -117,7 +122,7 @@ class Osi2RequestMapperTest {
 					.replyOption(PostfachNachricht.ReplyOption.POSSIBLE)
 					.build();
 
-			var result = mapper.mapOutSendMessageRequestV2(nachricht);
+			var result = mapper.mapOutSendMessageRequestV2(nachricht, emptyList());
 
 			assertThat(result.getReplyAction()).isEqualTo(V1ReplyBehavior.REPLYPOSSIBLE);
 		}
@@ -129,7 +134,7 @@ class Osi2RequestMapperTest {
 					.replyOption(PostfachNachricht.ReplyOption.MANDATORY)
 					.build();
 
-			var result = mapper.mapOutSendMessageRequestV2(nachricht);
+			var result = mapper.mapOutSendMessageRequestV2(nachricht, emptyList());
 
 			assertThat(result.getReplyAction()).isEqualTo(V1ReplyBehavior.REPLYMANDATORY);
 		}
@@ -158,10 +163,42 @@ class Osi2RequestMapperTest {
 			assertThat(result.getIsHtml()).isFalse();
 		}
 
+		@DisplayName("should map files if empty")
+		@Test
+		void shouldMapFilesIfEmpty() {
+			var result = doMapping();
+
+			assertThat(result.getFiles()).isEmpty();
+		}
+
+		@DisplayName("should map two files")
+		@Test
+		void shouldMapTwoFiles() {
+			var files = List.of(
+					Osi2FileUploadTestFactory.create(),
+					Osi2FileUploadTestFactory.createBuilder()
+							.file(OzgCloudFileTestFactory.create())
+							.guid(UPLOAD_GUID2)
+							.build()
+			);
+			var nachricht = PostfachNachrichtTestFactory.createBuilder()
+					.attachments(List.of(UPLOAD_FILE_ID, OzgCloudFileTestFactory.ID_STR))
+					.build();
+
+			var result = mapper.mapOutSendMessageRequestV2(nachricht, files);
+
+			assertThat(result.getFiles())
+					.usingRecursiveComparison()
+					.isEqualTo(files.stream().map(mapper::mapMessageExchangeFile).toList());
+		}
+
 		@DisplayName("should map files")
 		@Test
 		void shouldMapFiles() {
-			var result = doMapping();
+			var result = mapper.mapOutSendMessageRequestV2(
+					PostfachNachrichtTestFactory.create(),
+					List.of(Osi2FileUploadTestFactory.create())
+			);
 
 			assertThat(result.getFiles()).isEmpty();
 		}
@@ -175,7 +212,114 @@ class Osi2RequestMapperTest {
 		}
 
 		private OutSendMessageRequestV2 doMapping() {
-			return mapper.mapOutSendMessageRequestV2(PostfachNachrichtTestFactory.create());
+			return mapper.mapOutSendMessageRequestV2(PostfachNachrichtTestFactory.create(), emptyList());
+		}
+	}
+
+	@DisplayName("map message exchange file")
+	@Nested
+	class TestMapOsi2MessageExchangeFile {
+		@DisplayName("should map messageGuid")
+		@Test
+		void shouldMapGuid() {
+			var result = mapFile();
+
+			assertThat(result.getGuid()).isEqualTo(UUID.fromString(UPLOAD_GUID));
+		}
+
+		@DisplayName("should map mimeType")
+		@Test
+		void shouldMapMimeType() {
+			var result = mapFile();
+
+			assertThat(result.getMimeType()).isEqualTo(UPLOAD_CONTENT_TYPE);
+		}
+
+		@DisplayName("should map name")
+		@Test
+		void shouldMapName() {
+			var result = mapFile();
+
+			assertThat(result.getName()).isEqualTo(UPLOAD_NAME);
+		}
+
+		@DisplayName("should map size")
+		@Test
+		void shouldMapSize() {
+			var result = mapFile();
+
+			assertThat(result.getSize()).isEqualTo(UPLOAD_SIZE);
+		}
+
+		private MessageExchangeFiles mapFile() {
+			return mapper.mapMessageExchangeFile(Osi2FileUploadTestFactory.create());
+		}
+	}
+
+	@DisplayName("map DomainChunkMetaData")
+	@Nested
+	class TestMapDomainChunkMetaData {
+
+		private final FileChunkInfo chunkInfo = FileChunkInfoTestFactory.create();
+
+		@DisplayName("should map upload messageGuid")
+		@Test
+		void shouldMapUploadGuid() {
+			var result = doMapping();
+
+			assertThat(result.getUploadUid()).isEqualTo(UUID.fromString(UPLOAD_GUID));
+		}
+
+		@DisplayName("should map file name")
+		@Test
+		void shouldMapFileName() {
+			var result = doMapping();
+
+			assertThat(result.getFileName()).isEqualTo(UPLOAD_NAME);
+		}
+
+		@DisplayName("should map content type")
+		@Test
+		void shouldMapContentType() {
+			var result = doMapping();
+
+			assertThat(result.getContentType()).isEqualTo(UPLOAD_CONTENT_TYPE);
+		}
+
+		@DisplayName("should map chunk index")
+		@Test
+		void shouldMapChunkIndex() {
+			var result = doMapping();
+
+			assertThat(result.getChunkIndex()).isEqualTo(FileChunkInfoTestFactory.CHUNK_INDEX);
+		}
+
+		@DisplayName("should map total chunks")
+		@Test
+		void shouldMapTotalChunks() {
+			var result = doMapping();
+
+			assertThat(result.getTotalChunks()).isEqualTo((int) NUMBER_OF_CHUNKS);
+		}
+
+		@DisplayName("should map total file size")
+		@Test
+		void shouldMapTotalFileSize() {
+			var result = doMapping();
+
+			assertThat(result.getTotalFileSize()).isEqualTo(UPLOAD_SIZE);
+		}
+
+		@DisplayName("should set target to unspecified")
+		@Test
+		void shouldSetTargetToUnspecified() {
+			var result = doMapping();
+
+			assertThat(result.getTarget()).isEqualTo(V1FilestorageTarget.UNSPECIFIED);
+		}
+
+		private DomainChunkMetaData doMapping() {
+			return mapper.mapDomainChunkMetaData(chunkInfo);
 		}
 	}
 
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java
index 8806cc719991a35cc7891a72b853542473eed084..ff5a336d4e16028c186505786504021ef711f814 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/Osi2ResponseMapperTest.java
@@ -1,5 +1,7 @@
 package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.QuarantineFileResultTestFactory.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyFilesTestFactory.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory.*;
 import static org.assertj.core.api.Assertions.*;
 
@@ -16,9 +18,19 @@ import org.mapstruct.factory.Mappers;
 import org.mockito.InjectMocks;
 
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2RuntimeException;
+import de.ozgcloud.nachrichten.postfach.osiv2.exception.Osi2UploadException;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.QuarantineFileResultTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyFilesTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyBehavior;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyFiles;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyMessage;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.AttachmentInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+import lombok.SneakyThrows;
 
 class Osi2ResponseMapperTest {
 
@@ -26,22 +38,22 @@ class Osi2ResponseMapperTest {
 	private Osi2ResponseMapper mapper = Mappers.getMapper(Osi2ResponseMapper.class);
 	private final V1ReplyMessage message = V1ReplyMessageTestFactory.create();
 
-	@DisplayName("map V1ReplyMessage to PostfachNachricht")
+	@DisplayName("toMessage")
 	@Nested
-	class V1ReplyMessageToPostfachNachricht {
+	class TestToMessage {
 
 		@Test
 		void shouldMapVorgangId() {
 			var result = doMapping();
 
-			assertThat(result.getVorgangId()).isEqualTo(SEQUENCE_NUMMER);
+			assertThat(result.vorgangId()).isEqualTo(SEQUENCE_NUMMER);
 		}
 
 		@Test
 		void shouldMapPostfachAddress() {
 			var result = doMapping();
 
-			assertThat(result.getPostfachAddress().getIdentifier())
+			assertThat(result.postfachAddress().getIdentifier())
 					.hasToString(MESSAGE_BOX_ID);
 		}
 
@@ -49,53 +61,52 @@ class Osi2ResponseMapperTest {
 		void shouldMapCreatedAt() {
 			var result = doMapping();
 
-			assertThat(result.getCreatedAt()).isEqualTo(RESPONSE_TIME);
-		}
-
-		@Test
-		void shouldMapDirection() {
-			var result = doMapping();
-
-			assertThat(result.getDirection()).isEqualTo(PostfachNachricht.Direction.IN);
+			assertThat(result.createdAt()).isEqualTo(RESPONSE_TIME);
 		}
 
 		@Test
 		void shouldMapSubject() {
 			var result = doMapping();
 
-			assertThat(result.getSubject()).isEqualTo(SUBJECT);
+			assertThat(result.subject()).isEqualTo(SUBJECT);
 		}
 
 		@Test
 		void shouldMapNullBodyToEmptyString() {
-			var result = doMapping();
+			var noBodyMessage = V1ReplyMessageTestFactory.createBuilder()
+					.body(null)
+					.build();
+
+			var result = mapper.toMessage(noBodyMessage);
 
-			assertThat(result.getMailBody()).isEmpty();
+			assertThat(result.mailBody()).isEmpty();
 		}
 
 		@DisplayName("should map modified HTML body if HTML message")
 		@Test
 		void shouldMapModifiedHtmlBodyIfHtmlMessage() {
-			var htmlMessage = V1ReplyMessageTestFactory.create()
+			var htmlMessage = V1ReplyMessageTestFactory.createBuilder()
 					.body(HTML_REPLY_BODY)
-					.isHtml(true);
+					.isHtml(true)
+					.build();
 
-			var result = mapper.toPostfachNachricht(htmlMessage);
+			var result = mapper.toMessage(htmlMessage);
 
-			assertThat(result.getMailBody()).isEqualTo(REPLY_BODY);
+			assertThat(result.mailBody()).isEqualTo(REPLY_BODY);
 		}
 
 		@DisplayName("should map unmodified body if not HTML message")
 		@ParameterizedTest
 		@ValueSource(strings = { REPLY_BODY, HTML_REPLY_BODY })
 		void shouldMapUnmodifiedBodyIfNotHtmlMessage(String body) {
-			var htmlMessage = V1ReplyMessageTestFactory.create()
+			var htmlMessage = V1ReplyMessageTestFactory.createBuilder()
 					.body(body)
-					.isHtml(false);
+					.isHtml(false)
+					.build();
 
-			var result = mapper.toPostfachNachricht(htmlMessage);
+			var result = mapper.toMessage(htmlMessage);
 
-			assertThat(result.getMailBody()).isEqualTo(body);
+			assertThat(result.mailBody()).isEqualTo(body);
 		}
 
 		static Stream<Arguments> replyOptionValues() {
@@ -110,13 +121,14 @@ class Osi2ResponseMapperTest {
 		@ParameterizedTest
 		@MethodSource("replyOptionValues")
 		void shouldMapReplyOption(V1ReplyBehavior replyAction, PostfachNachricht.ReplyOption expected) {
-			var replyActionMessage = V1ReplyMessageTestFactory.create()
+			var replyActionMessage = V1ReplyMessageTestFactory.createBuilder()
 					.replyAction(replyAction)
-					.isHtml(false);
+					.isHtml(false)
+					.build();
 
-			var result = mapper.toPostfachNachricht(replyActionMessage);
+			var result = mapper.toMessage(replyActionMessage);
 
-			assertThat(result.getReplyOption()).isEqualTo(expected);
+			assertThat(result.replyOption()).isEqualTo(expected);
 		}
 
 		@DisplayName("should map messageId")
@@ -124,7 +136,33 @@ class Osi2ResponseMapperTest {
 		void shouldMapMessageId() {
 			var result = doMapping();
 
-			assertThat(result.getMessageId()).isEqualTo(MESSAGE_ID);
+			assertThat(result.messageId()).isEqualTo(MESSAGE_ID);
+		}
+
+		@DisplayName("should map attachments")
+		@Test
+		void shouldMapAttachments() {
+			assert message.getFiles() != null;
+			var expectedAttachments = message.getFiles()
+					.stream()
+					.map(file -> mapper.toAttachmentInfo(file))
+					.toList();
+
+			var result = doMapping();
+
+			assertThat(result.attachments()).usingRecursiveComparison().isEqualTo(expectedAttachments);
+		}
+
+		@DisplayName("should map null attachments to empty list")
+		@Test
+		void shouldMapNullAttachmentsToEmptyList() {
+			var messageWithoutAttachments = V1ReplyMessageTestFactory.createBuilder()
+					.files(null)
+					.build();
+
+			var result = mapper.toMessage(messageWithoutAttachments);
+
+			assertThat(result.attachments()).isEmpty();
 		}
 
 		@DisplayName("should not fail if all fields are null")
@@ -132,7 +170,7 @@ class Osi2ResponseMapperTest {
 		void shouldNotFailIfAllFieldsAreNull() {
 			var nullMessage = new V1ReplyMessage();
 
-			assertThatCode(() -> mapper.toPostfachNachricht(nullMessage))
+			assertThatCode(() -> mapper.toMessage(nullMessage))
 					.doesNotThrowAnyException();
 		}
 
@@ -142,13 +180,119 @@ class Osi2ResponseMapperTest {
 			var nullMessage = new V1ReplyMessage()
 					.isHtml(true);
 
-			assertThatCode(() -> mapper.toPostfachNachricht(nullMessage))
+			assertThatCode(() -> mapper.toMessage(nullMessage))
 					.doesNotThrowAnyException();
 		}
 
-		private PostfachNachricht doMapping() {
-			return mapper.toPostfachNachricht(message);
+		private Osi2Message doMapping() {
+			return mapper.toMessage(message);
 		}
+	}
+
+	@DisplayName("to AttachmentInfo")
+	@Nested
+	class TestToAttachmentInfo {
+
+		private final V1ReplyFiles file = V1ReplyFilesTestFactory.create();
 
+		@DisplayName("should map guid")
+		@Test
+		void shouldMapGuid() {
+			var result = doMapping();
+
+			assertThat(result.guid()).isEqualTo(ATTACHMENT_GUID);
+		}
+
+		@DisplayName("should map name")
+		@Test
+		void shouldMapName() {
+			var result = doMapping();
+
+			assertThat(result.name()).isEqualTo(ATTACHMENT_NAME);
+		}
+
+		@DisplayName("should map size")
+		@Test
+		void shouldMapSize() {
+			var result = doMapping();
+
+			assertThat(result.size()).isEqualTo(ATTACHMENT_SIZE);
+		}
+
+		@DisplayName("should map content type")
+		@Test
+		void shouldMapContentType() {
+			var result = doMapping();
+
+			assertThat(result.contentType()).isEqualTo(ATTACHMENT_CONTENT_TYPE);
+		}
+
+		private AttachmentInfo doMapping() {
+			return mapper.toAttachmentInfo(file);
+		}
+	}
+
+	@DisplayName("is safe")
+	@Nested
+	class TestIsSafe {
+		@DisplayName("should throw for bad end status")
+		@ParameterizedTest
+		@ValueSource(strings = { "None", "Unsafe", "Corrupt", "Missing" })
+		void shouldThrowForBadEndStatus(String status) {
+			var uploadStatus = QuarantineStatus.fromValue(status);
+
+			assertThatThrownBy(() -> mapper.isSafe(uploadStatus))
+					.isInstanceOf(Osi2UploadException.class)
+					.hasMessage(status);
+		}
+
+		@DisplayName("should return false for unfinished status")
+		@ParameterizedTest
+		@ValueSource(strings = { "Legacy", "Commited" })
+		@SneakyThrows
+		void shouldReturnFalseForUnfinishedStatus(String status) {
+			var uploadStatus = QuarantineStatus.fromValue(status);
+
+			var result = mapper.isSafe(uploadStatus);
+
+			assertThat(result).isFalse();
+		}
+
+		@DisplayName("should return true for safe status")
+		@Test
+		@SneakyThrows
+		void shouldReturnTrueForSafeStatus() {
+			var result = mapper.isSafe(QuarantineStatus.SAFE);
+
+			assertThat(result).isTrue();
+		}
+	}
+
+	@DisplayName("check chunk upload success")
+	@Nested
+	class TestCheckChunkUploadSuccess {
+
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			var quarantineFileResult = QuarantineFileResultTestFactory.create();
+
+			mapper.checkChunkUploadSuccess(quarantineFileResult);
+		}
+
+		@DisplayName("should throw on failed upload")
+		@Test
+		void shouldThrowOnFailedUpload() {
+			var quarantineFileResult = QuarantineFileResultTestFactory.createBuilder()
+					.success(false)
+					.error(ERROR_STRING)
+					.fileUid(Osi2FileUploadTestFactory.UPLOAD_GUID)
+					.build();
+
+			assertThatThrownBy(() -> mapper.checkChunkUploadSuccess(quarantineFileResult))
+					.isInstanceOf(Osi2RuntimeException.class)
+					.hasMessageContaining(ERROR_STRING)
+					.hasMessageContaining(Osi2FileUploadTestFactory.UPLOAD_GUID);
+		}
 	}
 }
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java
index d2ceb286eaf9a6dfe46d5f79841fa0f63b08ea18..0e6e97f9263715e2f246471e0364cf0e351a6e03 100644
--- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/PostfachApiFacadeServiceTest.java
@@ -2,7 +2,7 @@ package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
 
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory.*;
 import static de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachAddressTestFactory.*;
-import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.PostfachApiFacadeService.*;
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.Osi2RequestMapper.*;
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
@@ -16,37 +16,54 @@ import org.junit.jupiter.api.Test;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Spy;
+import org.springframework.core.io.AbstractResource;
 
 import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
-import de.ozgcloud.nachrichten.postfach.osiv2.OsiPostfachException;
+import de.ozgcloud.nachrichten.postfach.osiv2.config.Osi2PostfachProperties;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.FileChunkInfoTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.MessageExchangeReceiveMessagesResponseTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2FileUploadTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.Osi2MessageTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.factory.PostfachNachrichtTestFactory;
+import de.ozgcloud.nachrichten.postfach.osiv2.factory.V1ReplyMessageTestFactory;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.MessageExchangeApi;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.api.QuarantineApi;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.DomainChunkMetaData;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeDeleteMessageResponse;
-import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessage;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeReceiveMessagesResponse;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.MessageExchangeSendMessageResponse;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.OutSendMessageRequestV2;
+import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.QuarantineStatus;
 import de.ozgcloud.nachrichten.postfach.osiv2.gen.model.V1ReplyMessage;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.FileChunkInfo;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Attachment;
+import de.ozgcloud.nachrichten.postfach.osiv2.model.Osi2Message;
+import lombok.SneakyThrows;
 
 class PostfachApiFacadeServiceTest {
 
 	@InjectMocks
 	@Spy
-	PostfachApiFacadeService postfachApiFacadeService;
+	PostfachApiFacadeService service;
 
 	@Mock
 	MessageExchangeApi messageExchangeApi;
 
+	@Mock
+	QuarantineApi quarantineApi;
+
 	@Mock
 	Osi2RequestMapper osi2RequestMapper;
 
 	@Mock
 	Osi2ResponseMapper osi2ResponseMapper;
 
+	@Mock
+	Osi2PostfachProperties.ApiConfiguration apiConfiguration;
+
 	@DisplayName("send message")
 	@Nested
-	class TestSendMessage {
+	class TestSendOsi2Message {
 
 		@Mock
 		OutSendMessageRequestV2 outSendMessageRequestV2;
@@ -54,28 +71,46 @@ class PostfachApiFacadeServiceTest {
 		@Mock
 		MessageExchangeSendMessageResponse messageExchangeSendMessageResponse;
 
+		private final List<Osi2Attachment> files = List.of(Osi2FileUploadTestFactory.create());
+
 		private final PostfachNachricht nachricht = PostfachNachrichtTestFactory.create();
 
 		@BeforeEach
 		void mock() {
-			when(osi2RequestMapper.mapMailboxId(nachricht)).thenReturn(MAILBOX_ID);
-			when(osi2RequestMapper.mapOutSendMessageRequestV2(nachricht)).thenReturn(outSendMessageRequestV2);
+			when(osi2RequestMapper.mapMailboxId(any())).thenReturn(MAILBOX_ID);
+			when(osi2RequestMapper.mapOutSendMessageRequestV2(any(), any())).thenReturn(outSendMessageRequestV2);
 			when(messageExchangeApi.sendMessage(any(), any())).thenReturn(messageExchangeSendMessageResponse);
 		}
 
+		@DisplayName("should call mapMailboxId")
+		@Test
+		void shouldCallMapMailboxId() {
+			service.sendMessage(nachricht, files);
+
+			verify(osi2RequestMapper).mapMailboxId(nachricht);
+		}
+
+		@DisplayName("should call mapOutSendMessageRequestV2")
+		@Test
+		void shouldCallMapOutSendMessageRequestV2() {
+			service.sendMessage(nachricht, files);
+
+			verify(osi2RequestMapper).mapOutSendMessageRequestV2(nachricht, files);
+		}
+
 		@DisplayName("should call sendMessage")
 		@Test
 		void shouldCallSendMessage() {
-			postfachApiFacadeService.sendMessage(nachricht);
+			service.sendMessage(nachricht, files);
 
 			verify(messageExchangeApi).sendMessage(MAILBOX_ID, outSendMessageRequestV2);
-
 		}
+
 	}
 
-	@DisplayName("receive messages")
+	@DisplayName("fetch pending message ids")
 	@Nested
-	class TestReceiveMessage {
+	class TestFetchPendingOsi2MessageIds {
 
 		@DisplayName("with two pending messages")
 		@Nested
@@ -87,26 +122,29 @@ class PostfachApiFacadeServiceTest {
 			@BeforeEach
 			void mock() {
 				when(messageExchangeApi.receiveMessages(anyInt(), anyInt())).thenReturn(response);
-				doReturn(PostfachNachrichtTestFactory.createBuilder().messageId(MESSAGE_ID_1).build())
-						.when(postfachApiFacadeService).fetchMessageByGuid(response.getMessages().get(0));
-				doReturn(PostfachNachrichtTestFactory.createBuilder().messageId(MESSAGE_ID_2).build())
-						.when(postfachApiFacadeService).fetchMessageByGuid(response.getMessages().get(1));
+				when(osi2ResponseMapper.toMessageIds(any())).thenReturn(List.of(MESSAGE_ID_1, MESSAGE_ID_2));
 			}
 
 			@DisplayName("should return")
 			@Test
 			void shouldReturn() {
-				var messages = receiveMessageList();
+				var messageIds = fetchMessageIds();
+
+				assertThat(messageIds).containsExactly(MESSAGE_ID_1, MESSAGE_ID_2);
+			}
+
+			@DisplayName("should call mapper")
+			@Test
+			void shouldCallMapper() {
+				fetchMessageIds();
 
-				assertThat(messages)
-						.extracting(PostfachNachricht::getMessageId)
-						.containsExactly(MESSAGE_ID_1, MESSAGE_ID_2);
+				verify(osi2ResponseMapper).toMessageIds(response);
 			}
 
 			@DisplayName("should call receiveMessages api method")
 			@Test
 			void shouldCallReceiveMessagesApiMethod() {
-				receiveMessageList();
+				fetchMessageIds();
 
 				verify(messageExchangeApi).receiveMessages(MAX_NUMBER_RECEIVED_MESSAGES, 0);
 			}
@@ -126,73 +164,140 @@ class PostfachApiFacadeServiceTest {
 			@DisplayName("should return")
 			@Test
 			void shouldReturn() {
-				var messages = receiveMessageList();
+				var messages = fetchMessageIds();
 
 				assertThat(messages).isEmpty();
 			}
 
 		}
 
-		@DisplayName("with null response")
-		@Nested
-		class TestWithNullResponse {
-
-			@DisplayName("should throw")
-			@Test
-			void shouldThrow() {
-				assertThatThrownBy(TestReceiveMessage.this::receiveMessageList)
-						.isInstanceOf(OsiPostfachException.class);
-			}
-		}
-
-		private List<PostfachNachricht> receiveMessageList() {
-			return postfachApiFacadeService.receiveMessages().toList();
+		private List<String> fetchMessageIds() {
+			return service.fetchPendingMessageIds();
 		}
 
 	}
 
-	@DisplayName("fetch Message by guid")
+	@DisplayName("fetch message by id")
 	@Nested
-	class TestFetchMessageByGuid {
+	class TestFetchMessageById {
 
-		@Mock
-		V1ReplyMessage replyMessage;
-		@Mock
-		MessageExchangeReceiveMessage receiveMessage;
+		private final V1ReplyMessage replyMessage = V1ReplyMessageTestFactory.create();
+		private final Osi2Message message = Osi2MessageTestFactory.create();
 
 		@Test
 		void shouldCallGetMessage() {
 			when(messageExchangeApi.getMessage(any())).thenReturn(replyMessage);
 
-			postfachApiFacadeService.fetchMessageByGuid(receiveMessage);
+			service.fetchMessageById(MESSAGE_ID_1);
 
-			verify(messageExchangeApi).getMessage(any());
+			verify(messageExchangeApi).getMessage(UUID.fromString(MESSAGE_ID_1));
 		}
 
 		@Test
 		void shouldCallResponseMapper() {
 			when(messageExchangeApi.getMessage(any())).thenReturn(replyMessage);
-			when(osi2ResponseMapper.toPostfachNachricht(any())).thenReturn(PostfachNachrichtTestFactory.create());
+			when(osi2ResponseMapper.toMessage(any())).thenReturn(Osi2MessageTestFactory.create());
 
-			postfachApiFacadeService.fetchMessageByGuid(receiveMessage);
+			service.fetchMessageById(MESSAGE_ID_1);
 
-			verify(osi2ResponseMapper).toPostfachNachricht(any());
+			verify(osi2ResponseMapper).toMessage(replyMessage);
 		}
 
+		@DisplayName("should return")
 		@Test
-		void shouldReturnPostfachNachricht() {
+		void shouldReturn() {
 			when(messageExchangeApi.getMessage(any())).thenReturn(replyMessage);
-			when(osi2ResponseMapper.toPostfachNachricht(any())).thenReturn(PostfachNachrichtTestFactory.create());
+			when(osi2ResponseMapper.toMessage(any())).thenReturn(message);
+
+			var result = service.fetchMessageById(MESSAGE_ID_1);
+
+			assertThat(result).isEqualTo(message);
+		}
+	}
+
+	@DisplayName("upload chunk")
+	@Nested
+	class TestUploadChunk {
+		@Mock
+		AbstractResource chunkResource;
+
+		@Mock
+		DomainChunkMetaData domainChunkMetaData;
+
+		private static final String TENANT = "tenant";
+		private static final String NAME_IDENTIFIER = "nameIdentifier";
 
-			var postfachNachricht = postfachApiFacadeService.fetchMessageByGuid(receiveMessage);
+		private final FileChunkInfo info = FileChunkInfoTestFactory.create();
 
-			assertThat(postfachNachricht).isInstanceOf(PostfachNachricht.class);
+		@BeforeEach
+		void mock() {
+			when(apiConfiguration.getTenant()).thenReturn(TENANT);
+			when(apiConfiguration.getNameIdentifier()).thenReturn(NAME_IDENTIFIER);
+			when(osi2RequestMapper.mapDomainChunkMetaData(any())).thenReturn(domainChunkMetaData);
+		}
+
+		@DisplayName("should call mapDomainChunkMetadata")
+		@Test
+		void shouldCallMapDomainChunkMetadata() {
+			service.uploadChunk(info, chunkResource);
+
+			verify(osi2RequestMapper).mapDomainChunkMetaData(info);
+		}
+
+		@DisplayName("should call uploadChunk")
+		@Test
+		void shouldCallUploadChunk() {
+			service.uploadChunk(info, chunkResource);
+
+			verify(quarantineApi).uploadChunk(domainChunkMetaData, TENANT, NAME_IDENTIFIER, chunkResource);
 		}
 	}
 
-	@DisplayName("Delete Message")
+	@DisplayName("check upload successful")
 	@Nested
-	class TestDeleteMessage {
+	class TestCheckUploadSuccessful {
+
+		private final QuarantineStatus uploadStatus = QuarantineStatus.SAFE;
+
+		@BeforeEach
+		@SneakyThrows
+		void mock() {
+			when(osi2ResponseMapper.isSafe(any())).thenReturn(true);
+			when(quarantineApi.getUploadStatus(any())).thenReturn(uploadStatus);
+		}
+
+		@DisplayName("should call getUploadStatus")
+		@Test
+		@SneakyThrows
+		void shouldCallGetUploadStatus() {
+			service.checkUploadSuccessful(MESSAGE_ID_1);
+
+			verify(quarantineApi).getUploadStatus(UUID.fromString(MESSAGE_ID_1));
+		}
+
+		@DisplayName("should call isSafe")
+		@Test
+		@SneakyThrows
+		void shouldCallIsSafe() {
+			service.checkUploadSuccessful(MESSAGE_ID_1);
+
+			verify(osi2ResponseMapper).isSafe(uploadStatus);
+		}
+
+		@DisplayName("should return true")
+		@Test
+		@SneakyThrows
+		void shouldReturnTrue() {
+			var result = service.checkUploadSuccessful(MESSAGE_ID_1);
+
+			assertThat(result).isTrue();
+		}
+
+	}
+
+	@DisplayName("delete message")
+	@Nested
+	class TestDeleteOsi2Message {
 		@Mock
 		MessageExchangeDeleteMessageResponse replyMessage;
 
@@ -200,9 +305,21 @@ class PostfachApiFacadeServiceTest {
 		void shouldCallDeleteMessage() {
 			when(messageExchangeApi.deleteMessage(any())).thenReturn(replyMessage);
 
-			postfachApiFacadeService.deleteMessage(UUID.randomUUID().toString());
+			service.deleteMessage(MESSAGE_ID_1);
+
+			verify(messageExchangeApi).deleteMessage(UUID.fromString(MESSAGE_ID_1));
+		}
+	}
+
+	@DisplayName("delete file upload")
+	@Nested
+	class TestDeleteFileUpload {
+		@DisplayName("should call deleteUpload")
+		@Test
+		void shouldCallDeleteUpload() {
+			service.deleteFileUpload(MESSAGE_ID_1);
 
-			verify(messageExchangeApi).deleteMessage(any());
+			verify(quarantineApi).deleteUpload(UUID.fromString(MESSAGE_ID_1));
 		}
 	}
 
diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c33e3e9d55fed284c6f9cd3037c226a344d9844a
--- /dev/null
+++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/transfer/WaitUtilTest.java
@@ -0,0 +1,81 @@
+package de.ozgcloud.nachrichten.postfach.osiv2.transfer;
+
+import static de.ozgcloud.nachrichten.postfach.osiv2.transfer.WaitUtil.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.awaitility.Awaitility.*;
+
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import lombok.SneakyThrows;
+
+class WaitUtilTest {
+
+	@DisplayName("wait until")
+	@Nested
+	class TestWaitUntil {
+		@DisplayName("should return true if check condition is eventually true")
+		@Test
+		void shouldReturnTrueIfCheckConditionIsEventuallyTrue() {
+			var delayFuture = createDelayedFuture(100);
+
+			var conditionFuture = CompletableFuture.supplyAsync(() -> waitForDelayedCondition(delayFuture));
+
+			await()
+					.between(Duration.ofMillis(100), Duration.ofMillis(500))
+					.until(conditionFuture::isDone);
+			assertThat(conditionFuture.getNow(false)).isTrue();
+		}
+
+		@DisplayName("should return false if check condition is not true before timeout")
+		@Test
+		void shouldReturnFalseIfCheckConditionIsNotTrueBeforeTimeout() {
+			var delayCondition = createDelayedFuture(1000);
+
+			var conditionFuture = CompletableFuture.supplyAsync(() -> waitForDelayedCondition(delayCondition));
+
+			await()
+					.between(Duration.ofMillis(500), Duration.ofMillis(1000))
+					.until(conditionFuture::isDone);
+			assertThat(conditionFuture.getNow(true)).isFalse();
+		}
+
+		@DisplayName("should rethrow runtime exceptions of condition")
+		@Test
+		void shouldRethrowRuntimeExceptionsOfCondition() {
+			var exception = new RuntimeException("test");
+
+			var conditionFuture = CompletableFuture.supplyAsync(() ->
+					waitUntil(() -> {
+						throw exception;
+					}, Duration.ofMillis(50), Duration.ofMillis(500)));
+
+			await()
+					.atMost(Duration.ofMillis(500))
+					.until(conditionFuture::isDone);
+			assertThatThrownBy(() -> conditionFuture.getNow(false))
+					.hasCause(exception);
+		}
+
+		private CompletableFuture<Boolean> createDelayedFuture(long milliseconds) {
+			return CompletableFuture.supplyAsync(() -> {
+				try {
+					Thread.sleep(milliseconds);
+					return true;
+				} catch (InterruptedException e) {
+					throw new RuntimeException(e);
+				}
+			});
+		}
+
+		@SneakyThrows
+		private boolean waitForDelayedCondition(CompletableFuture<Boolean> future) {
+			return waitUntil(future::isDone, Duration.ofMillis(50), Duration.ofMillis(500));
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/resources/application-itcase.yml b/src/test/resources/application-itcase.yml
index 41e84786e9e76db63c2d6671a55ae56dddae6ea7..37a4164e12939e2ce5689d44b0edf53d7cc01143 100644
--- a/src/test/resources/application-itcase.yml
+++ b/src/test/resources/application-itcase.yml
@@ -5,4 +5,11 @@ logging:
   level:
     de.ozgcloud.nachrichten.postfach.osiv2: DEBUG
     org.springframework.http: DEBUG
-    org.springframework.web.client: DEBUG
\ No newline at end of file
+    org.springframework.web.client: DEBUG
+
+spring:
+  grpc:
+    client:
+      file-manager:
+        address: static://127.0.0.1:9090
+        negotiationType: PLAINTEXT
\ No newline at end of file